ssh-to-code 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- config_manager.py +141 -0
- host_selector.py +237 -0
- save_config.py +340 -0
- ssh_dir_browser.py +362 -0
- ssh_handler.py +233 -0
- ssh_to_code-1.0.0.dist-info/METADATA +384 -0
- ssh_to_code-1.0.0.dist-info/RECORD +12 -0
- ssh_to_code-1.0.0.dist-info/WHEEL +5 -0
- ssh_to_code-1.0.0.dist-info/entry_points.txt +2 -0
- ssh_to_code-1.0.0.dist-info/licenses/LICENSE +21 -0
- ssh_to_code-1.0.0.dist-info/top_level.txt +6 -0
- vscode_integration.py +185 -0
save_config.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Interactive SSH Configuration Manager
|
|
4
|
+
Easily save and manage frequently used SSH connections
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
# Add current directory to path
|
|
11
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
12
|
+
|
|
13
|
+
from config_manager import ConfigManager
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def print_header(text):
|
|
17
|
+
print("\n" + "=" * 60)
|
|
18
|
+
print(f" {text}")
|
|
19
|
+
print("=" * 60 + "\n")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def add_host_interactive():
|
|
23
|
+
"""Interactive host addition"""
|
|
24
|
+
print_header("Add New SSH Host Configuration")
|
|
25
|
+
|
|
26
|
+
config = ConfigManager()
|
|
27
|
+
|
|
28
|
+
# Get host details
|
|
29
|
+
print("Enter the SSH connection details:\n")
|
|
30
|
+
|
|
31
|
+
name = input("Host nickname (e.g., 'my-ec2', 'production'): ").strip()
|
|
32
|
+
if not name:
|
|
33
|
+
print("❌ Name is required")
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
# Check if already exists
|
|
37
|
+
existing = config.get_host(name)
|
|
38
|
+
if existing:
|
|
39
|
+
print(f"\n⚠️ Host '{name}' already exists!")
|
|
40
|
+
print(f" Current config: {existing['username']}@{existing['hostname']}")
|
|
41
|
+
overwrite = input(" Overwrite? (yes/no): ").strip().lower()
|
|
42
|
+
if overwrite != 'yes':
|
|
43
|
+
print("Cancelled.")
|
|
44
|
+
return False
|
|
45
|
+
config.remove_host(name)
|
|
46
|
+
|
|
47
|
+
hostname = input("Hostname or IP (e.g., 'example.com', '1.2.3.4'): ").strip()
|
|
48
|
+
if not hostname:
|
|
49
|
+
print("❌ Hostname is required")
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
username = input("Username (e.g., 'ubuntu', 'ec2-user', 'root'): ").strip()
|
|
53
|
+
if not username:
|
|
54
|
+
print("❌ Username is required")
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
port_input = input("Port (default: 22): ").strip()
|
|
58
|
+
port = int(port_input) if port_input else 22
|
|
59
|
+
|
|
60
|
+
key_file = input("Path to SSH key file (optional, press Enter to skip): ").strip()
|
|
61
|
+
if key_file:
|
|
62
|
+
key_file = os.path.expanduser(key_file)
|
|
63
|
+
if not os.path.exists(key_file):
|
|
64
|
+
print(f"⚠️ Warning: Key file not found: {key_file}")
|
|
65
|
+
proceed = input(" Save anyway? (yes/no): ").strip().lower()
|
|
66
|
+
if proceed != 'yes':
|
|
67
|
+
return False
|
|
68
|
+
else:
|
|
69
|
+
key_file = None
|
|
70
|
+
|
|
71
|
+
default_path = input("Default starting directory (optional, press Enter to skip): ").strip()
|
|
72
|
+
if not default_path:
|
|
73
|
+
default_path = None
|
|
74
|
+
|
|
75
|
+
# Confirm
|
|
76
|
+
print("\n" + "-" * 60)
|
|
77
|
+
print("Configuration Summary:")
|
|
78
|
+
print("-" * 60)
|
|
79
|
+
print(f" Name: {name}")
|
|
80
|
+
print(f" Connection: {username}@{hostname}:{port}")
|
|
81
|
+
if key_file:
|
|
82
|
+
print(f" Key file: {key_file}")
|
|
83
|
+
if default_path:
|
|
84
|
+
print(f" Start path: {default_path}")
|
|
85
|
+
print("-" * 60)
|
|
86
|
+
|
|
87
|
+
confirm = input("\nSave this configuration? (yes/no): ").strip().lower()
|
|
88
|
+
if confirm == 'yes':
|
|
89
|
+
success = config.add_host(
|
|
90
|
+
name=name,
|
|
91
|
+
hostname=hostname,
|
|
92
|
+
username=username,
|
|
93
|
+
port=port,
|
|
94
|
+
key_file=key_file,
|
|
95
|
+
default_path=default_path
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if success:
|
|
99
|
+
print(f"\n✅ Configuration '{name}' saved successfully!")
|
|
100
|
+
print(f"\nYou can now connect with:")
|
|
101
|
+
print(f" ./ssh-browse {name}")
|
|
102
|
+
print(f" # or")
|
|
103
|
+
print(f" python ssh_dir_browser.py {username}@{hostname} -p {port}", end="")
|
|
104
|
+
if key_file:
|
|
105
|
+
print(f" -i {key_file}", end="")
|
|
106
|
+
print()
|
|
107
|
+
return True
|
|
108
|
+
else:
|
|
109
|
+
print("\n❌ Failed to save configuration")
|
|
110
|
+
return False
|
|
111
|
+
else:
|
|
112
|
+
print("Cancelled.")
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def list_hosts():
|
|
117
|
+
"""List all saved hosts"""
|
|
118
|
+
print_header("Saved SSH Host Configurations")
|
|
119
|
+
|
|
120
|
+
config = ConfigManager()
|
|
121
|
+
hosts = config.get_hosts()
|
|
122
|
+
|
|
123
|
+
if not hosts:
|
|
124
|
+
print("No saved configurations found.\n")
|
|
125
|
+
print("💡 Add one with: python save_config.py add")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
print(f"Found {len(hosts)} saved configuration(s):\n")
|
|
129
|
+
|
|
130
|
+
for i, host in enumerate(hosts, 1):
|
|
131
|
+
print(f"{i}. {host['name']}")
|
|
132
|
+
print(f" Connection: {host['username']}@{host['hostname']}:{host.get('port', 22)}")
|
|
133
|
+
if 'key_file' in host:
|
|
134
|
+
print(f" Key file: {host['key_file']}")
|
|
135
|
+
if 'default_path' in host:
|
|
136
|
+
print(f" Start path: {host['default_path']}")
|
|
137
|
+
if 'last_path' in host:
|
|
138
|
+
print(f" Last used: {host['last_path']}")
|
|
139
|
+
print()
|
|
140
|
+
|
|
141
|
+
print("Connect with: ./ssh-browse <name>")
|
|
142
|
+
print(f"Config file: {config.config_path}\n")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def remove_host_interactive():
|
|
146
|
+
"""Interactive host removal"""
|
|
147
|
+
print_header("Remove SSH Host Configuration")
|
|
148
|
+
|
|
149
|
+
config = ConfigManager()
|
|
150
|
+
hosts = config.get_hosts()
|
|
151
|
+
|
|
152
|
+
if not hosts:
|
|
153
|
+
print("No saved configurations found.\n")
|
|
154
|
+
return
|
|
155
|
+
|
|
156
|
+
print("Saved configurations:\n")
|
|
157
|
+
for i, host in enumerate(hosts, 1):
|
|
158
|
+
print(f"{i}. {host['name']} ({host['username']}@{host['hostname']})")
|
|
159
|
+
|
|
160
|
+
print()
|
|
161
|
+
choice = input("Enter name or number to remove (or 'cancel'): ").strip()
|
|
162
|
+
|
|
163
|
+
if choice.lower() == 'cancel':
|
|
164
|
+
print("Cancelled.")
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
# Try as number first
|
|
168
|
+
host_to_remove = None
|
|
169
|
+
try:
|
|
170
|
+
idx = int(choice) - 1
|
|
171
|
+
if 0 <= idx < len(hosts):
|
|
172
|
+
host_to_remove = hosts[idx]['name']
|
|
173
|
+
except ValueError:
|
|
174
|
+
host_to_remove = choice
|
|
175
|
+
|
|
176
|
+
if host_to_remove:
|
|
177
|
+
confirm = input(f"Remove '{host_to_remove}'? (yes/no): ").strip().lower()
|
|
178
|
+
if confirm == 'yes':
|
|
179
|
+
if config.remove_host(host_to_remove):
|
|
180
|
+
print(f"✅ Removed '{host_to_remove}'")
|
|
181
|
+
else:
|
|
182
|
+
print(f"❌ Host '{host_to_remove}' not found")
|
|
183
|
+
else:
|
|
184
|
+
print("Cancelled.")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def quick_add_from_command():
|
|
188
|
+
"""Quick add from command line arguments"""
|
|
189
|
+
if len(sys.argv) < 4:
|
|
190
|
+
print("Usage: python save_config.py quick <name> <user@host> [options]")
|
|
191
|
+
print("\nOptions:")
|
|
192
|
+
print(" -i <key_file> Path to SSH key")
|
|
193
|
+
print(" -p <port> SSH port (default: 22)")
|
|
194
|
+
print(" --path <dir> Default starting directory")
|
|
195
|
+
print("\nExample:")
|
|
196
|
+
print(" python save_config.py quick myserver ubuntu@example.com -i ~/key.pem --path /var/www")
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
name = sys.argv[2]
|
|
200
|
+
user_host = sys.argv[3]
|
|
201
|
+
|
|
202
|
+
if '@' not in user_host:
|
|
203
|
+
print("❌ Invalid format. Use: user@hostname")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
username, hostname = user_host.split('@', 1)
|
|
207
|
+
|
|
208
|
+
# Parse options
|
|
209
|
+
port = 22
|
|
210
|
+
key_file = None
|
|
211
|
+
default_path = None
|
|
212
|
+
|
|
213
|
+
i = 4
|
|
214
|
+
while i < len(sys.argv):
|
|
215
|
+
if sys.argv[i] == '-i' and i + 1 < len(sys.argv):
|
|
216
|
+
key_file = os.path.expanduser(sys.argv[i + 1])
|
|
217
|
+
i += 2
|
|
218
|
+
elif sys.argv[i] == '-p' and i + 1 < len(sys.argv):
|
|
219
|
+
port = int(sys.argv[i + 1])
|
|
220
|
+
i += 2
|
|
221
|
+
elif sys.argv[i] == '--path' and i + 1 < len(sys.argv):
|
|
222
|
+
default_path = sys.argv[i + 1]
|
|
223
|
+
i += 2
|
|
224
|
+
else:
|
|
225
|
+
i += 1
|
|
226
|
+
|
|
227
|
+
config = ConfigManager()
|
|
228
|
+
|
|
229
|
+
if config.add_host(name, hostname, username, port, key_file, default_path):
|
|
230
|
+
print(f"✅ Configuration '{name}' saved!")
|
|
231
|
+
print(f"\nConnect with: ./ssh-browse {name}")
|
|
232
|
+
else:
|
|
233
|
+
print(f"❌ Failed to save configuration")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def show_help():
|
|
237
|
+
"""Show help message"""
|
|
238
|
+
print("""
|
|
239
|
+
SSH Directory Browser - Configuration Manager
|
|
240
|
+
==============================================
|
|
241
|
+
|
|
242
|
+
USAGE:
|
|
243
|
+
python save_config.py [command]
|
|
244
|
+
|
|
245
|
+
COMMANDS:
|
|
246
|
+
add, a Add new host configuration (interactive)
|
|
247
|
+
list, ls, l List all saved configurations
|
|
248
|
+
remove, rm, r Remove a host configuration
|
|
249
|
+
quick <name> ... Quick add from command line
|
|
250
|
+
help, h Show this help message
|
|
251
|
+
|
|
252
|
+
EXAMPLES:
|
|
253
|
+
|
|
254
|
+
1. Interactive mode (recommended for first time):
|
|
255
|
+
python save_config.py add
|
|
256
|
+
|
|
257
|
+
2. Quick add from command line:
|
|
258
|
+
python save_config.py quick myec2 ubuntu@ec2-1-2-3-4.compute.amazonaws.com -i ~/aws-key.pem
|
|
259
|
+
|
|
260
|
+
3. List all saved hosts:
|
|
261
|
+
python save_config.py list
|
|
262
|
+
|
|
263
|
+
4. Remove a host:
|
|
264
|
+
python save_config.py remove
|
|
265
|
+
|
|
266
|
+
5. After saving, connect with:
|
|
267
|
+
./ssh-browse myec2
|
|
268
|
+
|
|
269
|
+
CONFIGURATION FILE:
|
|
270
|
+
Location: ~/.ssh-dir-browser.json
|
|
271
|
+
Format: JSON
|
|
272
|
+
|
|
273
|
+
DIRECT EDITING:
|
|
274
|
+
You can also edit the config file directly:
|
|
275
|
+
nano ~/.ssh-dir-browser.json
|
|
276
|
+
|
|
277
|
+
Example format:
|
|
278
|
+
{
|
|
279
|
+
"version": "1.0",
|
|
280
|
+
"hosts": [
|
|
281
|
+
{
|
|
282
|
+
"name": "myserver",
|
|
283
|
+
"hostname": "example.com",
|
|
284
|
+
"username": "user",
|
|
285
|
+
"port": 22,
|
|
286
|
+
"key_file": "~/.ssh/id_rsa",
|
|
287
|
+
"default_path": "/var/www"
|
|
288
|
+
}
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
""")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def main():
|
|
295
|
+
"""Main entry point"""
|
|
296
|
+
if len(sys.argv) < 2:
|
|
297
|
+
# Interactive mode by default
|
|
298
|
+
while True:
|
|
299
|
+
print("\n" + "=" * 60)
|
|
300
|
+
print(" SSH Directory Browser - Configuration Manager")
|
|
301
|
+
print("=" * 60)
|
|
302
|
+
print("\n1. Add new host")
|
|
303
|
+
print("2. List saved hosts")
|
|
304
|
+
print("3. Remove host")
|
|
305
|
+
print("4. Exit")
|
|
306
|
+
|
|
307
|
+
choice = input("\nSelect option (1-4): ").strip()
|
|
308
|
+
|
|
309
|
+
if choice == '1':
|
|
310
|
+
add_host_interactive()
|
|
311
|
+
elif choice == '2':
|
|
312
|
+
list_hosts()
|
|
313
|
+
elif choice == '3':
|
|
314
|
+
remove_host_interactive()
|
|
315
|
+
elif choice == '4':
|
|
316
|
+
print("\nGoodbye!")
|
|
317
|
+
break
|
|
318
|
+
else:
|
|
319
|
+
print("Invalid option")
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
command = sys.argv[1].lower()
|
|
323
|
+
|
|
324
|
+
if command in ['add', 'a']:
|
|
325
|
+
add_host_interactive()
|
|
326
|
+
elif command in ['list', 'ls', 'l']:
|
|
327
|
+
list_hosts()
|
|
328
|
+
elif command in ['remove', 'rm', 'r']:
|
|
329
|
+
remove_host_interactive()
|
|
330
|
+
elif command == 'quick':
|
|
331
|
+
quick_add_from_command()
|
|
332
|
+
elif command in ['help', 'h', '--help', '-h']:
|
|
333
|
+
show_help()
|
|
334
|
+
else:
|
|
335
|
+
print(f"Unknown command: {command}")
|
|
336
|
+
print("Run 'python save_config.py help' for usage information")
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
if __name__ == "__main__":
|
|
340
|
+
main()
|
ssh_dir_browser.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SSH Directory Browser with VS Code Integration
|
|
4
|
+
Browse remote directories via SSH and open them in VS Code
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import curses
|
|
8
|
+
import sys
|
|
9
|
+
import subprocess
|
|
10
|
+
import os
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
import argparse
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class DirectoryBrowser:
|
|
16
|
+
"""Interactive terminal-based directory browser"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, ssh_handler, start_path: str = "/"):
|
|
19
|
+
self.ssh_handler = ssh_handler
|
|
20
|
+
self.current_path = start_path
|
|
21
|
+
self.selected_index = 0
|
|
22
|
+
self.scroll_offset = 0
|
|
23
|
+
self.items: List[Tuple[str, str]] = [] # (name, type)
|
|
24
|
+
self.status_message = ""
|
|
25
|
+
|
|
26
|
+
def get_directory_contents(self) -> List[Tuple[str, str]]:
|
|
27
|
+
"""Get directory contents from remote server"""
|
|
28
|
+
try:
|
|
29
|
+
# List directory with details (-F adds indicators for file types)
|
|
30
|
+
cmd = f"ls -1F '{self.current_path}'"
|
|
31
|
+
output = self.ssh_handler.execute_command(cmd)
|
|
32
|
+
|
|
33
|
+
if not output:
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
items = []
|
|
37
|
+
# Add parent directory option if not at root
|
|
38
|
+
if self.current_path != "/":
|
|
39
|
+
items.append(("..", "dir"))
|
|
40
|
+
|
|
41
|
+
for line in output.strip().split('\n'):
|
|
42
|
+
if not line:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
# Check file type indicator
|
|
46
|
+
if line.endswith('/'):
|
|
47
|
+
items.append((line[:-1], "dir"))
|
|
48
|
+
elif line.endswith('*'):
|
|
49
|
+
items.append((line[:-1], "exec"))
|
|
50
|
+
elif line.endswith('@'):
|
|
51
|
+
items.append((line[:-1], "link"))
|
|
52
|
+
else:
|
|
53
|
+
items.append((line, "file"))
|
|
54
|
+
|
|
55
|
+
return items
|
|
56
|
+
except Exception as e:
|
|
57
|
+
self.status_message = f"Error: {str(e)}"
|
|
58
|
+
return []
|
|
59
|
+
|
|
60
|
+
def navigate_to(self, item_name: str):
|
|
61
|
+
"""Navigate to a directory"""
|
|
62
|
+
if item_name == "..":
|
|
63
|
+
# Go to parent directory
|
|
64
|
+
self.current_path = os.path.dirname(self.current_path)
|
|
65
|
+
if not self.current_path:
|
|
66
|
+
self.current_path = "/"
|
|
67
|
+
else:
|
|
68
|
+
# Go to subdirectory
|
|
69
|
+
if self.current_path == "/":
|
|
70
|
+
self.current_path = f"/{item_name}"
|
|
71
|
+
else:
|
|
72
|
+
self.current_path = f"{self.current_path}/{item_name}"
|
|
73
|
+
|
|
74
|
+
self.selected_index = 0
|
|
75
|
+
self.scroll_offset = 0
|
|
76
|
+
self.items = self.get_directory_contents()
|
|
77
|
+
|
|
78
|
+
def create_folder(self, stdscr) -> Optional[str]:
|
|
79
|
+
"""Prompt user to create a new folder"""
|
|
80
|
+
height, width = stdscr.getmaxyx()
|
|
81
|
+
|
|
82
|
+
# Create input window
|
|
83
|
+
input_win = curses.newwin(5, min(60, width - 4), height // 2 - 2, (width - min(60, width - 4)) // 2)
|
|
84
|
+
input_win.box()
|
|
85
|
+
input_win.addstr(1, 2, "Create New Folder:", curses.A_BOLD)
|
|
86
|
+
input_win.addstr(2, 2, "Name: ")
|
|
87
|
+
input_win.refresh()
|
|
88
|
+
|
|
89
|
+
# Enable cursor for input
|
|
90
|
+
curses.curs_set(1)
|
|
91
|
+
curses.echo()
|
|
92
|
+
|
|
93
|
+
# Get folder name
|
|
94
|
+
folder_name = ""
|
|
95
|
+
try:
|
|
96
|
+
folder_name = input_win.getstr(2, 8, 40).decode('utf-8').strip()
|
|
97
|
+
except:
|
|
98
|
+
pass
|
|
99
|
+
finally:
|
|
100
|
+
curses.noecho()
|
|
101
|
+
curses.curs_set(0)
|
|
102
|
+
|
|
103
|
+
if not folder_name:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# Validate folder name
|
|
107
|
+
if '/' in folder_name or folder_name in ['.', '..']:
|
|
108
|
+
return f"Invalid folder name: {folder_name}"
|
|
109
|
+
|
|
110
|
+
# Create folder on remote server
|
|
111
|
+
try:
|
|
112
|
+
new_path = os.path.join(self.current_path, folder_name)
|
|
113
|
+
cmd = f"mkdir -p '{new_path}'"
|
|
114
|
+
self.ssh_handler.execute_command(cmd)
|
|
115
|
+
return f"Created folder: {folder_name}"
|
|
116
|
+
except Exception as e:
|
|
117
|
+
return f"Error creating folder: {str(e)}"
|
|
118
|
+
|
|
119
|
+
def open_in_vscode(self):
|
|
120
|
+
"""Open current directory in VS Code"""
|
|
121
|
+
try:
|
|
122
|
+
host = self.ssh_handler.hostname
|
|
123
|
+
user = self.ssh_handler.username
|
|
124
|
+
port = self.ssh_handler.port
|
|
125
|
+
|
|
126
|
+
# VS Code Remote-SSH URI format
|
|
127
|
+
# vscode://vscode-remote/ssh-remote+user@host:port/path
|
|
128
|
+
remote_uri = f"vscode://vscode-remote/ssh-remote+{user}@{host}"
|
|
129
|
+
if port != 22:
|
|
130
|
+
remote_uri += f":{port}"
|
|
131
|
+
remote_uri += self.current_path
|
|
132
|
+
|
|
133
|
+
# Open VS Code
|
|
134
|
+
subprocess.Popen(['code', '--remote', f'ssh-remote+{user}@{host}', self.current_path],
|
|
135
|
+
stdout=subprocess.DEVNULL,
|
|
136
|
+
stderr=subprocess.DEVNULL)
|
|
137
|
+
|
|
138
|
+
return True, f"Opening {self.current_path} in VS Code..."
|
|
139
|
+
except Exception as e:
|
|
140
|
+
return False, f"Error opening VS Code: {str(e)}"
|
|
141
|
+
|
|
142
|
+
def draw_ui(self, stdscr):
|
|
143
|
+
"""Draw the terminal UI"""
|
|
144
|
+
stdscr.clear()
|
|
145
|
+
height, width = stdscr.getmaxyx()
|
|
146
|
+
|
|
147
|
+
# Header
|
|
148
|
+
header = f"SSH Directory Browser - {self.ssh_handler.username}@{self.ssh_handler.hostname}"
|
|
149
|
+
stdscr.addstr(0, 0, header[:width-1], curses.A_REVERSE)
|
|
150
|
+
|
|
151
|
+
# Current path
|
|
152
|
+
path_line = f"Path: {self.current_path}"
|
|
153
|
+
stdscr.addstr(1, 0, path_line[:width-1], curses.A_BOLD)
|
|
154
|
+
|
|
155
|
+
# Help line
|
|
156
|
+
help_text = "↑/↓: Navigate | Enter: Open | 'o': VS Code | 'n': New Folder | 'r': Refresh | 'q': Quit"
|
|
157
|
+
stdscr.addstr(2, 0, help_text[:width-1], curses.A_DIM)
|
|
158
|
+
|
|
159
|
+
# Separator
|
|
160
|
+
stdscr.addstr(3, 0, "─" * (width-1))
|
|
161
|
+
|
|
162
|
+
# Directory contents
|
|
163
|
+
display_height = height - 6 # Reserve space for header, footer, and status
|
|
164
|
+
|
|
165
|
+
# Calculate visible range
|
|
166
|
+
if self.selected_index < self.scroll_offset:
|
|
167
|
+
self.scroll_offset = self.selected_index
|
|
168
|
+
elif self.selected_index >= self.scroll_offset + display_height:
|
|
169
|
+
self.scroll_offset = self.selected_index - display_height + 1
|
|
170
|
+
|
|
171
|
+
# Display items
|
|
172
|
+
for i in range(self.scroll_offset, min(len(self.items), self.scroll_offset + display_height)):
|
|
173
|
+
y = 4 + i - self.scroll_offset
|
|
174
|
+
name, item_type = self.items[i]
|
|
175
|
+
|
|
176
|
+
# Format display
|
|
177
|
+
if item_type == "dir":
|
|
178
|
+
display = f"📁 {name}/"
|
|
179
|
+
attr = curses.A_BOLD
|
|
180
|
+
elif item_type == "exec":
|
|
181
|
+
display = f"⚙️ {name}*"
|
|
182
|
+
attr = curses.A_NORMAL
|
|
183
|
+
elif item_type == "link":
|
|
184
|
+
display = f"🔗 {name}@"
|
|
185
|
+
attr = curses.A_DIM
|
|
186
|
+
else:
|
|
187
|
+
display = f"📄 {name}"
|
|
188
|
+
attr = curses.A_NORMAL
|
|
189
|
+
|
|
190
|
+
# Highlight selected item
|
|
191
|
+
if i == self.selected_index:
|
|
192
|
+
attr |= curses.A_REVERSE
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
stdscr.addstr(y, 2, display[:width-3], attr)
|
|
196
|
+
except curses.error:
|
|
197
|
+
pass # Ignore errors from writing to last line
|
|
198
|
+
|
|
199
|
+
# Status bar
|
|
200
|
+
status_y = height - 2
|
|
201
|
+
stdscr.addstr(status_y, 0, "─" * (width-1))
|
|
202
|
+
|
|
203
|
+
if self.status_message:
|
|
204
|
+
status = self.status_message[:width-1]
|
|
205
|
+
stdscr.addstr(status_y + 1, 0, status, curses.A_BOLD)
|
|
206
|
+
else:
|
|
207
|
+
info = f"Items: {len(self.items)} | Selected: {self.selected_index + 1}/{len(self.items)}"
|
|
208
|
+
stdscr.addstr(status_y + 1, 0, info[:width-1])
|
|
209
|
+
|
|
210
|
+
stdscr.refresh()
|
|
211
|
+
|
|
212
|
+
def run(self, stdscr):
|
|
213
|
+
"""Main event loop"""
|
|
214
|
+
curses.curs_set(0) # Hide cursor
|
|
215
|
+
stdscr.keypad(True)
|
|
216
|
+
|
|
217
|
+
# Initialize colors if available
|
|
218
|
+
if curses.has_colors():
|
|
219
|
+
curses.init_pair(1, curses.COLOR_CYAN, curses.COLOR_BLACK)
|
|
220
|
+
curses.init_pair(2, curses.COLOR_GREEN, curses.COLOR_BLACK)
|
|
221
|
+
|
|
222
|
+
# Load initial directory
|
|
223
|
+
self.items = self.get_directory_contents()
|
|
224
|
+
|
|
225
|
+
while True:
|
|
226
|
+
self.draw_ui(stdscr)
|
|
227
|
+
|
|
228
|
+
# Get user input
|
|
229
|
+
key = stdscr.getch()
|
|
230
|
+
|
|
231
|
+
if key == ord('q') or key == ord('Q'):
|
|
232
|
+
break
|
|
233
|
+
elif key == curses.KEY_UP:
|
|
234
|
+
if self.selected_index > 0:
|
|
235
|
+
self.selected_index -= 1
|
|
236
|
+
self.status_message = ""
|
|
237
|
+
elif key == curses.KEY_DOWN:
|
|
238
|
+
if self.selected_index < len(self.items) - 1:
|
|
239
|
+
self.selected_index += 1
|
|
240
|
+
self.status_message = ""
|
|
241
|
+
elif key == ord('\n') or key == curses.KEY_ENTER:
|
|
242
|
+
# Enter selected item
|
|
243
|
+
if self.items and self.selected_index < len(self.items):
|
|
244
|
+
name, item_type = self.items[self.selected_index]
|
|
245
|
+
if item_type == "dir" or name == "..":
|
|
246
|
+
self.navigate_to(name)
|
|
247
|
+
self.status_message = ""
|
|
248
|
+
else:
|
|
249
|
+
self.status_message = f"'{name}' is not a directory"
|
|
250
|
+
elif key == ord('o') or key == ord('O'):
|
|
251
|
+
# Open in VS Code
|
|
252
|
+
success, message = self.open_in_vscode()
|
|
253
|
+
self.status_message = message
|
|
254
|
+
if success:
|
|
255
|
+
# Wait a moment to show the message, then exit
|
|
256
|
+
stdscr.timeout(2000)
|
|
257
|
+
stdscr.getch()
|
|
258
|
+
break
|
|
259
|
+
elif key == ord('h') or key == ord('H'):
|
|
260
|
+
# Go to home directory
|
|
261
|
+
home_cmd = "echo $HOME"
|
|
262
|
+
home_path = self.ssh_handler.execute_command(home_cmd).strip()
|
|
263
|
+
if home_path:
|
|
264
|
+
self.current_path = home_path
|
|
265
|
+
self.selected_index = 0
|
|
266
|
+
self.scroll_offset = 0
|
|
267
|
+
self.items = self.get_directory_contents()
|
|
268
|
+
self.status_message = f"Navigated to home: {home_path}"
|
|
269
|
+
elif key == ord('r') or key == ord('R'):
|
|
270
|
+
# Refresh
|
|
271
|
+
self.items = self.get_directory_contents()
|
|
272
|
+
self.status_message = "Refreshed"
|
|
273
|
+
elif key == ord('n') or key == ord('N'):
|
|
274
|
+
# Create new folder
|
|
275
|
+
result = self.create_folder(stdscr)
|
|
276
|
+
if result:
|
|
277
|
+
self.status_message = result
|
|
278
|
+
# Refresh directory listing
|
|
279
|
+
self.items = self.get_directory_contents()
|
|
280
|
+
else:
|
|
281
|
+
self.status_message = "Folder creation cancelled"
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def main():
|
|
285
|
+
"""Main entry point"""
|
|
286
|
+
parser = argparse.ArgumentParser(
|
|
287
|
+
description="SSH Directory Browser - Browse remote directories and open in VS Code",
|
|
288
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
289
|
+
epilog="""
|
|
290
|
+
Examples:
|
|
291
|
+
%(prog)s user@hostname
|
|
292
|
+
%(prog)s user@hostname -p 2222
|
|
293
|
+
%(prog)s user@hostname --start-path /var/www
|
|
294
|
+
%(prog)s user@hostname -i ~/.ssh/id_rsa
|
|
295
|
+
"""
|
|
296
|
+
)
|
|
297
|
+
parser.add_argument('host', help='SSH host in format user@hostname')
|
|
298
|
+
parser.add_argument('-p', '--port', type=int, default=22, help='SSH port (default: 22)')
|
|
299
|
+
parser.add_argument('-i', '--identity', help='SSH private key file')
|
|
300
|
+
parser.add_argument('--start-path', default='~', help='Starting directory (default: ~)')
|
|
301
|
+
parser.add_argument('--password', action='store_true',
|
|
302
|
+
help='Prompt for password authentication')
|
|
303
|
+
|
|
304
|
+
args = parser.parse_args()
|
|
305
|
+
|
|
306
|
+
# Parse user@host
|
|
307
|
+
if '@' not in args.host:
|
|
308
|
+
print("Error: Host must be in format user@hostname")
|
|
309
|
+
sys.exit(1)
|
|
310
|
+
|
|
311
|
+
username, hostname = args.host.split('@', 1)
|
|
312
|
+
|
|
313
|
+
# Import SSH handler
|
|
314
|
+
try:
|
|
315
|
+
from ssh_handler import SSHHandler
|
|
316
|
+
except ImportError:
|
|
317
|
+
print("Error: ssh_handler module not found")
|
|
318
|
+
print("Make sure ssh_handler.py is in the same directory")
|
|
319
|
+
sys.exit(1)
|
|
320
|
+
|
|
321
|
+
# Connect to SSH
|
|
322
|
+
print(f"Connecting to {username}@{hostname}:{args.port}...")
|
|
323
|
+
|
|
324
|
+
try:
|
|
325
|
+
ssh = SSHHandler(
|
|
326
|
+
hostname=hostname,
|
|
327
|
+
username=username,
|
|
328
|
+
port=args.port,
|
|
329
|
+
key_filename=args.identity,
|
|
330
|
+
use_password=args.password
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if not ssh.connect():
|
|
334
|
+
print("Failed to connect to SSH server")
|
|
335
|
+
sys.exit(1)
|
|
336
|
+
|
|
337
|
+
print("Connected successfully!")
|
|
338
|
+
|
|
339
|
+
# Resolve start path
|
|
340
|
+
start_path = args.start_path
|
|
341
|
+
if start_path == '~':
|
|
342
|
+
home_cmd = "echo $HOME"
|
|
343
|
+
start_path = ssh.execute_command(home_cmd).strip() or "/"
|
|
344
|
+
|
|
345
|
+
# Start the browser
|
|
346
|
+
browser = DirectoryBrowser(ssh, start_path)
|
|
347
|
+
curses.wrapper(browser.run)
|
|
348
|
+
|
|
349
|
+
# Cleanup
|
|
350
|
+
ssh.disconnect()
|
|
351
|
+
print("\nDisconnected. Goodbye!")
|
|
352
|
+
|
|
353
|
+
except KeyboardInterrupt:
|
|
354
|
+
print("\n\nInterrupted by user")
|
|
355
|
+
sys.exit(0)
|
|
356
|
+
except Exception as e:
|
|
357
|
+
print(f"\nError: {str(e)}")
|
|
358
|
+
sys.exit(1)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__":
|
|
362
|
+
main()
|