Add tree-based file browser for snapshot contents

Replace flat file list with a Tree widget that shows directory
structure. Strips remote path prefix to show relative paths only.
Folders shown in bold with trailing /, sorted dirs-first.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shuki
2026-03-06 05:11:53 +02:00
parent 98fc263a40
commit 5b4bf33520
4 changed files with 121 additions and 9 deletions

View File

@@ -245,6 +245,35 @@ SelectionList {
margin: 0; 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 */
#log-viewer { #log-viewer {
height: 1fr; height: 1fr;

View File

@@ -1,3 +1,5 @@
import re
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, Select, DataTable 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.config import list_conf_dir
from tui.backend import run_cli from tui.backend import run_cli
from tui.widgets import OperationLog from tui.widgets import SnapshotBrowser
class SnapshotsScreen(Screen): class SnapshotsScreen(Screen):
@@ -91,17 +93,33 @@ class SnapshotsScreen(Screen):
return return
target = str(target_sel.value) target = str(target_sel.value)
remote = str(remote_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( rc, stdout, stderr = await run_cli(
"snapshots", "browse", f"--target={target}", f"--remote={remote}", f"--snapshot={snapshot}" "snapshots", "browse", f"--target={target}", f"--remote={remote}", f"--snapshot={snapshot}"
) )
if stdout:
log_screen.write(stdout) # Parse file list, strip remote prefix to get relative paths
if stderr: # Paths look like: /remote/base/.../snapshots/<timestamp>/etc/foo.conf
log_screen.write(stderr) # We want everything after the snapshot timestamp directory
if not stdout and not stderr: files = []
log_screen.write("No files found.") 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: def action_go_back(self) -> None:
self.app.pop_screen() self.app.pop_screen()

View File

@@ -2,3 +2,4 @@ from tui.widgets.folder_picker import FolderPicker
from tui.widgets.file_picker import FilePicker from tui.widgets.file_picker import FilePicker
from tui.widgets.confirm_dialog import ConfirmDialog from tui.widgets.confirm_dialog import ConfirmDialog
from tui.widgets.operation_log import OperationLog from tui.widgets.operation_log import OperationLog
from tui.widgets.snapshot_browser import SnapshotBrowser

View File

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