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