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:
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
114
tui/widgets/remote_folder_picker.py
Normal file
114
tui/widgets/remote_folder_picker.py
Normal 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)
|
||||||
Reference in New Issue
Block a user