From fb508d9e0b593fa69d21d32b792e55a3b15f4aa3 Mon Sep 17 00:00:00 2001 From: shuki Date: Sat, 7 Mar 2026 03:32:30 +0200 Subject: [PATCH] Add remote folder browser for SSH source targets Browse button now opens an SSH directory tree picker when source type is SSH, using the configured credentials. Folders input and Browse button placed on same row so they're visible without scrolling. Co-Authored-By: Claude Opus 4.6 --- tui/gniza.tcss | 9 ++- tui/screens/target_edit.py | 35 ++++++--- tui/widgets/__init__.py | 1 + tui/widgets/remote_folder_picker.py | 114 ++++++++++++++++++++++++++++ 4 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 tui/widgets/remote_folder_picker.py diff --git a/tui/gniza.tcss b/tui/gniza.tcss index 6f5fb85..8c3faf4 100644 --- a/tui/gniza.tcss +++ b/tui/gniza.tcss @@ -111,16 +111,19 @@ SelectionList { } /* Browse row */ -#restore-dest-row { +#restore-dest-row, +#te-folders-row { height: auto; margin: 0 0 1 0; } -#restore-dest-row Input { +#restore-dest-row Input, +#te-folders-row Input { width: 1fr; } -#restore-dest-row Button { +#restore-dest-row Button, +#te-folders-row Button { width: auto; min-width: 12; margin: 0 0 0 1; diff --git a/tui/screens/target_edit.py b/tui/screens/target_edit.py index 1fe2255..c0fde27 100644 --- a/tui/screens/target_edit.py +++ b/tui/screens/target_edit.py @@ -7,7 +7,7 @@ from textual.containers import Vertical, Horizontal from tui.config import parse_conf, write_conf, CONFIG_DIR, list_conf_dir from tui.models import Target -from tui.widgets import FolderPicker, DocsPanel +from tui.widgets import FolderPicker, RemoteFolderPicker, DocsPanel _NAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_-]{0,31}$') @@ -74,8 +74,9 @@ class TargetEditScreen(Screen): yield Static("Root Folder ID:", classes="source-field source-gdrive-field") yield Input(value=target.source_gdrive_root_folder_id, placeholder="folder ID", id="te-source-gdrive-root-folder-id", classes="source-field source-gdrive-field") yield Static("Folders (comma-separated):") - yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders") - yield Button("Browse...", id="btn-browse") + with Horizontal(id="te-folders-row"): + yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders") + yield Button("Browse...", id="btn-browse") yield Static("Include patterns:") yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include") yield Static("Exclude patterns:") @@ -172,17 +173,33 @@ class TargetEditScreen(Screen): elif source_type == "gdrive": for w in self.query(".source-gdrive-field"): w.display = True - # Hide browse button when remote - self.query_one("#btn-browse", Button).display = not is_remote def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "btn-cancel": self.dismiss(None) elif event.button.id == "btn-browse": - self.app.push_screen( - FolderPicker("Select folder to back up"), - callback=self._folder_selected, - ) + source_type = str(self.query_one("#te-source-type", Select).value) + if source_type == "ssh": + host = self.query_one("#te-source-host", Input).value.strip() + if not host: + self.notify("Enter Source Host first", severity="error") + return + self.app.push_screen( + RemoteFolderPicker( + host=host, + user=self.query_one("#te-source-user", Input).value.strip() or "root", + port=self.query_one("#te-source-port", Input).value.strip() or "22", + auth_method=str(self.query_one("#te-source-auth-method", Select).value), + key=self.query_one("#te-source-key", Input).value.strip(), + password=self.query_one("#te-source-password", Input).value.strip(), + ), + callback=self._folder_selected, + ) + else: + self.app.push_screen( + FolderPicker("Select folder to back up"), + callback=self._folder_selected, + ) elif event.button.id == "btn-save": self._save() diff --git a/tui/widgets/__init__.py b/tui/widgets/__init__.py index aaa17c7..467061e 100644 --- a/tui/widgets/__init__.py +++ b/tui/widgets/__init__.py @@ -5,3 +5,4 @@ from tui.widgets.operation_log import OperationLog from tui.widgets.snapshot_browser import SnapshotBrowser from tui.widgets.docs_panel import DocsPanel, HelpModal from tui.widgets.header import GnizaHeader +from tui.widgets.remote_folder_picker import RemoteFolderPicker diff --git a/tui/widgets/remote_folder_picker.py b/tui/widgets/remote_folder_picker.py new file mode 100644 index 0000000..ff50923 --- /dev/null +++ b/tui/widgets/remote_folder_picker.py @@ -0,0 +1,114 @@ +import subprocess + +from textual.app import ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Tree, Static, Button +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") + 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) + else: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None)