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 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-07 03:32:30 +02:00
parent 04a0c45abc
commit fb508d9e0b
4 changed files with 147 additions and 12 deletions

View File

@@ -111,16 +111,19 @@ SelectionList {
} }
/* Browse row */ /* Browse row */
#restore-dest-row { #restore-dest-row,
#te-folders-row {
height: auto; height: auto;
margin: 0 0 1 0; margin: 0 0 1 0;
} }
#restore-dest-row Input { #restore-dest-row Input,
#te-folders-row Input {
width: 1fr; width: 1fr;
} }
#restore-dest-row Button { #restore-dest-row Button,
#te-folders-row Button {
width: auto; width: auto;
min-width: 12; min-width: 12;
margin: 0 0 0 1; margin: 0 0 0 1;

View File

@@ -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.config import parse_conf, write_conf, CONFIG_DIR, list_conf_dir
from tui.models import Target 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}$') _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 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 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 Static("Folders (comma-separated):")
yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders") with Horizontal(id="te-folders-row"):
yield Button("Browse...", id="btn-browse") yield Input(value=target.folders, placeholder="/path1,/path2", id="te-folders")
yield Button("Browse...", id="btn-browse")
yield Static("Include patterns:") yield Static("Include patterns:")
yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include") yield Input(value=target.include, placeholder="*.conf,docs/", id="te-include")
yield Static("Exclude patterns:") yield Static("Exclude patterns:")
@@ -172,17 +173,33 @@ class TargetEditScreen(Screen):
elif source_type == "gdrive": elif source_type == "gdrive":
for w in self.query(".source-gdrive-field"): for w in self.query(".source-gdrive-field"):
w.display = True 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: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "btn-cancel": if event.button.id == "btn-cancel":
self.dismiss(None) self.dismiss(None)
elif event.button.id == "btn-browse": elif event.button.id == "btn-browse":
self.app.push_screen( source_type = str(self.query_one("#te-source-type", Select).value)
FolderPicker("Select folder to back up"), if source_type == "ssh":
callback=self._folder_selected, 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": elif event.button.id == "btn-save":
self._save() self._save()

View File

@@ -5,3 +5,4 @@ from tui.widgets.operation_log import OperationLog
from tui.widgets.snapshot_browser import SnapshotBrowser from tui.widgets.snapshot_browser import SnapshotBrowser
from tui.widgets.docs_panel import DocsPanel, HelpModal from tui.widgets.docs_panel import DocsPanel, HelpModal
from tui.widgets.header import GnizaHeader from tui.widgets.header import GnizaHeader
from tui.widgets.remote_folder_picker import RemoteFolderPicker

View File

@@ -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)