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;
}
/* 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;

View File

@@ -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/<timestamp>/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()

View File

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

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)