diff --git a/tui/gniza.tcss b/tui/gniza.tcss index fe38b9b..a81c1ca 100644 --- a/tui/gniza.tcss +++ b/tui/gniza.tcss @@ -245,6 +245,35 @@ SelectionList { margin: 0; } +/* Snapshot browser */ +#snapshot-browser { + width: 80%; + height: 80%; + padding: 1; + background: $panel; + border: thick $accent; +} + +#sb-title { + text-style: bold; + color: #00cc00; + margin: 0 0 1 0; +} + +#sb-tree { + height: 1fr; + border: round $accent; +} + +#sb-buttons { + height: auto; + margin: 1 0 0 0; +} + +#sb-buttons Button { + margin: 0 1 0 0; +} + /* Log viewer */ #log-viewer { height: 1fr; diff --git a/tui/screens/snapshots.py b/tui/screens/snapshots.py index ce16bd9..1b178b4 100644 --- a/tui/screens/snapshots.py +++ b/tui/screens/snapshots.py @@ -1,3 +1,5 @@ +import re + from textual.app import ComposeResult from textual.screen import Screen from textual.widgets import Header, Footer, Static, Button, Select, DataTable @@ -6,7 +8,7 @@ from textual import work from tui.config import list_conf_dir from tui.backend import run_cli -from tui.widgets import OperationLog +from tui.widgets import SnapshotBrowser class SnapshotsScreen(Screen): @@ -91,17 +93,33 @@ class SnapshotsScreen(Screen): return target = str(target_sel.value) remote = str(remote_sel.value) - log_screen = OperationLog(f"Files: {target}/{snapshot}", show_spinner=False) - self.app.push_screen(log_screen) + + self.notify("Loading files...") rc, stdout, stderr = await run_cli( "snapshots", "browse", f"--target={target}", f"--remote={remote}", f"--snapshot={snapshot}" ) - if stdout: - log_screen.write(stdout) - if stderr: - log_screen.write(stderr) - if not stdout and not stderr: - log_screen.write("No files found.") + + # Parse file list, strip remote prefix to get relative paths + # Paths look like: /remote/base/.../snapshots//etc/foo.conf + # We want everything after the snapshot timestamp directory + files = [] + pattern = re.compile(re.escape(snapshot) + r"/(.*)") + for line in (stdout or "").splitlines(): + line = line.strip() + if not line: + continue + m = pattern.search(line) + if m: + files.append(m.group(1)) + else: + files.append(line) + + if not files: + self.notify("No files found in snapshot", severity="warning") + return + + browser = SnapshotBrowser(f"{target} / {snapshot}", files) + self.app.push_screen(browser) def action_go_back(self) -> None: self.app.pop_screen() diff --git a/tui/widgets/__init__.py b/tui/widgets/__init__.py index a35da25..01337db 100644 --- a/tui/widgets/__init__.py +++ b/tui/widgets/__init__.py @@ -2,3 +2,4 @@ from tui.widgets.folder_picker import FolderPicker from tui.widgets.file_picker import FilePicker from tui.widgets.confirm_dialog import ConfirmDialog from tui.widgets.operation_log import OperationLog +from tui.widgets.snapshot_browser import SnapshotBrowser diff --git a/tui/widgets/snapshot_browser.py b/tui/widgets/snapshot_browser.py new file mode 100644 index 0000000..63e9d15 --- /dev/null +++ b/tui/widgets/snapshot_browser.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from pathlib import PurePosixPath +from textual.app import ComposeResult +from textual.screen import ModalScreen +from textual.widgets import Tree, Static, Button +from textual.containers import Vertical, Horizontal + + +class SnapshotBrowser(ModalScreen[None]): + """Modal file browser for remote snapshot contents.""" + + BINDINGS = [("escape", "close", "Close")] + + def __init__(self, title: str, file_list: list[str]): + super().__init__() + self._title = title + self._file_list = file_list + + def compose(self) -> ComposeResult: + with Vertical(id="snapshot-browser"): + yield Static(self._title, id="sb-title") + yield Tree("snapshot", id="sb-tree") + with Horizontal(id="sb-buttons"): + yield Button("Close", variant="primary", id="sb-close") + + def on_mount(self) -> None: + tree = self.query_one("#sb-tree", Tree) + tree.root.expand() + self._build_tree(tree) + + def _build_tree(self, tree: Tree) -> None: + # Build a nested dict from file paths + root: dict = {} + for filepath in self._file_list: + filepath = filepath.strip() + if not filepath: + continue + parts = PurePosixPath(filepath).parts + node = root + for part in parts: + if part not in node: + node[part] = {} + node = node[part] + + # Add to tree widget recursively + self._add_nodes(tree.root, root) + tree.root.expand_all() + + def _add_nodes(self, parent, structure: dict) -> None: + # Sort: directories first, then files + dirs = sorted(k for k, v in structure.items() if v) + files = sorted(k for k, v in structure.items() if not v) + for name in dirs: + branch = parent.add(f"[bold]{name}/[/bold]") + self._add_nodes(branch, structure[name]) + for name in files: + parent.add_leaf(name) + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(None) + + def action_close(self) -> None: + self.dismiss(None)