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)