- Rename all user-facing strings: Target→Source, Remote→Destination across TUI, docs, web dashboard, bash scripts, config examples, and README - Add go-to-path search input to both FolderPicker and RemoteFolderPicker Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
143 lines
4.9 KiB
Python
143 lines
4.9 KiB
Python
import subprocess
|
|
|
|
from textual.app import ComposeResult
|
|
from textual.screen import ModalScreen
|
|
from textual.widgets import Tree, Static, Button, Input
|
|
from textual.containers import Horizontal, Vertical
|
|
|
|
|
|
class RemoteFolderPicker(ModalScreen[str | None]):
|
|
"""Browse directories on a remote SSH host."""
|
|
|
|
BINDINGS = [("escape", "cancel", "Cancel")]
|
|
|
|
def __init__(
|
|
self,
|
|
host: str,
|
|
user: str = "root",
|
|
port: str = "22",
|
|
auth_method: str = "key",
|
|
key: str = "",
|
|
password: str = "",
|
|
title: str = "Select remote folder",
|
|
):
|
|
super().__init__()
|
|
self._title = title
|
|
self._host = host
|
|
self._user = user
|
|
self._port = port
|
|
self._auth_method = auth_method
|
|
self._key = key
|
|
self._password = password
|
|
|
|
def compose(self) -> ComposeResult:
|
|
with Vertical(id="folder-picker"):
|
|
yield Static(f"{self._title} ({self._user}@{self._host})", id="fp-title")
|
|
with Horizontal(id="fp-search-row"):
|
|
yield Input(placeholder="Go to path (e.g. /var/www)", id="fp-search")
|
|
yield Button("Go", id="fp-go", variant="primary")
|
|
yield Tree("/", id="fp-remote-tree")
|
|
with Horizontal(id="fp-buttons"):
|
|
yield Button("Select", variant="primary", id="fp-select")
|
|
yield Button("Cancel", variant="default", id="fp-cancel")
|
|
|
|
def on_mount(self) -> None:
|
|
tree = self.query_one("#fp-remote-tree", Tree)
|
|
tree.root.data = "/"
|
|
tree.root.set_label("/")
|
|
tree.root.allow_expand = True
|
|
self._load_children(tree.root, "/")
|
|
|
|
def _ssh_cmd(self) -> list[str]:
|
|
ssh_opts = [
|
|
"ssh",
|
|
"-o", "BatchMode=yes",
|
|
"-o", "StrictHostKeyChecking=accept-new",
|
|
"-o", "ConnectTimeout=10",
|
|
"-p", self._port,
|
|
]
|
|
if self._auth_method == "key" and self._key:
|
|
ssh_opts += ["-i", self._key]
|
|
ssh_opts.append(f"{self._user}@{self._host}")
|
|
if self._auth_method == "password" and self._password:
|
|
return ["sshpass", "-p", self._password] + ssh_opts
|
|
return ssh_opts
|
|
|
|
def _list_dirs(self, path: str) -> list[str]:
|
|
cmd = self._ssh_cmd() + [
|
|
f"find {path!r} -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort"
|
|
]
|
|
try:
|
|
result = subprocess.run(
|
|
cmd, capture_output=True, text=True, timeout=15,
|
|
)
|
|
if result.returncode != 0:
|
|
return []
|
|
dirs = []
|
|
for line in result.stdout.strip().splitlines():
|
|
line = line.strip()
|
|
if line and line != path:
|
|
dirs.append(line)
|
|
return dirs
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
return []
|
|
|
|
def _load_children(self, node, path: str) -> None:
|
|
dirs = self._list_dirs(path)
|
|
node.remove_children()
|
|
if not dirs:
|
|
return
|
|
for d in dirs:
|
|
name = d.rstrip("/").rsplit("/", 1)[-1]
|
|
child = node.add(name, data=d, allow_expand=True)
|
|
# Add a placeholder so the expand arrow shows
|
|
child.add_leaf("...", data=None)
|
|
|
|
def on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
|
|
node = event.node
|
|
if node.data is None:
|
|
return
|
|
# Check if children are just the placeholder
|
|
children = list(node.children)
|
|
if len(children) == 1 and children[0].data is None:
|
|
self._load_children(node, node.data)
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
if event.button.id == "fp-select":
|
|
tree = self.query_one("#fp-remote-tree", Tree)
|
|
node = tree.cursor_node
|
|
if node and node.data:
|
|
self.dismiss(str(node.data))
|
|
else:
|
|
self.dismiss(None)
|
|
elif event.button.id == "fp-go":
|
|
self._go_to_path()
|
|
else:
|
|
self.dismiss(None)
|
|
|
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
if event.input.id == "fp-search":
|
|
self._go_to_path()
|
|
|
|
def _go_to_path(self) -> None:
|
|
raw = self.query_one("#fp-search", Input).value.strip()
|
|
if not raw:
|
|
return
|
|
path = raw if raw.startswith("/") else "/" + raw
|
|
dirs = self._list_dirs(path)
|
|
tree = self.query_one("#fp-remote-tree", Tree)
|
|
tree.clear()
|
|
tree.root.data = path
|
|
tree.root.set_label(path)
|
|
if not dirs:
|
|
self.notify(f"No subdirectories in {path}", severity="warning")
|
|
return
|
|
for d in dirs:
|
|
name = d.rstrip("/").rsplit("/", 1)[-1]
|
|
child = tree.root.add(name, data=d, allow_expand=True)
|
|
child.add_leaf("...", data=None)
|
|
tree.root.expand()
|
|
|
|
def action_cancel(self) -> None:
|
|
self.dismiss(None)
|