Source code for exlab_wizard.ui.components.file_list

"""Live file-list component for the rebuilt main window.

GUI/Orchestrator Redesign §4.3, §5. Renders the immediate contents of a
folder (name / size / modified / per-file sync status) and exposes a pure
diff function that drives the new-file highlight.

The renderer is a pure function kept free of session-store / API deps
(matches the existing components pattern). The folder feed (§5) is
expected to call ``render_file_list`` with the current entry list; the
caller decides when to invoke and when to stop the underlying poll.

Right-click context menu (Redesign §4.3 / decision 6A): selecting a row
opens a menu with **Open in OS**, **Copy path**, and **Keep local**
actions; the right metadata pane is NOT driven by file-list selection.

Operator-free per-file NAS sync design (2026-05-21): a row can be a
**tombstone** -- a file present in the run's ``sync_state.json`` but
absent on disk (an "On NAS" cleared-run file). A tombstone is not
openable. A ``keep_local`` file carries a small "kept local" badge.
"""

from __future__ import annotations

from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from typing import Any

from exlab_wizard.ui.components.empty_state import empty_state
from exlab_wizard.ui.components.file_type_icon import file_type_icon
from exlab_wizard.ui.components.sync_status_icon import file_sync_view, sync_pair_icons
from exlab_wizard.ui.pages.staging import format_bytes

# Action discriminators consumed by the on_context_menu callback.
FILE_CONTEXT_OPEN = "open_in_os"
FILE_CONTEXT_COPY_PATH = "copy_path"
FILE_CONTEXT_KEEP_LOCAL = "keep_local"


[docs] @dataclass(frozen=True) class FileListEntry: """One row in the centre-pane file list. ``keep_local`` mirrors the file's ``sync_state.json`` keep-local flag (excluded from cleanup deletion). ``tombstone`` marks an "On NAS" row -- a file recorded in ``sync_state.json`` but absent on disk; such a row shows the ``on_nas`` icon and is not openable. """ name: str path: str is_dir: bool size_bytes: int | None = None modified_iso: str | None = None sync_status: str | None = None keep_local: bool = False tombstone: bool = False
[docs] @dataclass class FileListState: """Mutable state for the file list, consumed by the renderer.""" path: str = "" entries: list[FileListEntry] = field(default_factory=list) new_paths: frozenset[str] = field(default_factory=frozenset) """Paths that appeared in the most recent diff -- briefly highlighted.""" selected_path: str | None = None """Path of the row the operator single-clicked (Phase 4 / Option B); the matching row carries the selected fill + accent bar via row_background."""
[docs] @dataclass(frozen=True) class FileListDiff: """Result of comparing two successive folder-list snapshots.""" added: tuple[str, ...] removed: tuple[str, ...] modified: tuple[str, ...]
[docs] def diff_file_lists( previous: Iterable[FileListEntry], current: Iterable[FileListEntry], ) -> FileListDiff: """Return additions / removals / modifications between two snapshots. Pure function. An entry is "modified" when its ``path`` is present in both lists but its (size, modified_iso, sync_status) tuple differs. Used by the renderer to drive the new-file highlight and is unit- testable without spinning up NiceGUI. """ prev_map = {e.path: e for e in previous} curr_map = {e.path: e for e in current} prev_paths = set(prev_map) curr_paths = set(curr_map) added = tuple(sorted(curr_paths - prev_paths)) removed = tuple(sorted(prev_paths - curr_paths)) modified: list[str] = [] for path in sorted(curr_paths & prev_paths): before = prev_map[path] after = curr_map[path] if ( before.size_bytes != after.size_bytes or before.modified_iso != after.modified_iso or before.sync_status != after.sync_status or before.keep_local != after.keep_local or before.tombstone != after.tombstone ): modified.append(path) return FileListDiff( added=added, removed=removed, modified=tuple(modified), )
[docs] def row_background( entry: FileListEntry, *, is_selected: bool, is_new: bool, index: int, ) -> str: """Return the CSS background + decoration fragment for one file-list row. Pure function (no NiceGUI) so the row-state precedence is testable in isolation. Implements the spec's row-state stack (Redesign §4.2). **Background** -- first matching tier wins, top to bottom: 1. Selected -> ``--color-row-selected`` fill + inset left accent bar. 2. New-file -> ``--color-highlight`` (the just-arrived flash). 3. Tombstone -> *no* fill (explicitly suppresses the zebra stripe). 4. Zebra -> ``--color-zebra`` on odd ``index`` rows. 5. (otherwise) -> no background. **Decoration** -- applied *additively* whenever the row is a tombstone, independent of which background tier won, so a *selected* tombstone keeps both the selected fill and the dim/italic "On NAS" treatment. Zebra parity is computed from ``index`` (the row's position in the rendered list), not CSS ``:nth-child``, so interleaved selected / new / tombstone rows never shift the stripe pattern and the server-side re-render stays deterministic. Odd ``index`` values (the 2nd, 4th, ... rows) are striped, matching the even-row shading in the approved mockup. The constant ``border-bottom`` rule is the caller's concern; this returns only the state-dependent fragment. """ # Every token carries a literal fallback (the design.py value): the root # theme (build_root_css / register_theme) is not injected on the /main # route, so a bare var(--color-row-selected) resolves to empty and the # whole declaration is dropped -- the row would show no fill. The # fallbacks make the zebra / selection / new-file shading render # regardless, matching the discipline in framed_pane and the toggle tab. parts: list[str] = [] if is_selected: parts.append("background: var(--color-row-selected, #dceaff);") parts.append("box-shadow: inset 3px 0 0 var(--color-row-selected-bar, #1b75bc);") elif is_new: parts.append("background: var(--color-highlight, #fff6e0);") elif entry.tombstone: # Tombstone tier contributes no fill -- it deliberately suppresses the # zebra stripe so the dim/italic treatment below reads cleanly. pass elif index % 2 == 1: parts.append("background: var(--color-zebra, #f7f9fb);") if entry.tombstone: parts.append("opacity: 0.65;") parts.append("font-style: italic;") return " ".join(parts)
[docs] def render_file_list( *, state: FileListState, on_double_click: Callable[[FileListEntry], None] | None = None, on_context_menu: Callable[[FileListEntry, str], None] | None = None, on_select: Callable[[FileListEntry], None] | None = None, ) -> Any: # pragma: no cover -- NiceGUI render, driven by e2e """Render the centre-pane file list. Pure render function. Double-clicking a folder navigates into it; double-clicking a file asks the OS to open it. Single-click (``on_select``) selects the row -- files **and** folders -- so its metadata appears in the right pane and the row picks up the selected fill + accent bar (Phase 4 / Option B, spec §4.3). The right-click context menu is unchanged. """ try: from nicegui import ui except Exception: return {"state": state} with ui.column().classes("w-full h-full").style("gap: 0;") as container: if not state.entries: empty_state( icon="folder_open", message="This folder is empty.", testid="file-list-empty", ) return container with ( ui.element("table") .classes("w-full") .style("border-collapse: collapse; font-family: var(--font-mono);") .props('data-testid="file-list-table"') ): with ui.element("thead"): _render_header() with ui.element("tbody"): for index, entry in enumerate(state.entries): _render_row( entry, index=index, is_new=entry.path in state.new_paths, is_selected=entry.path == state.selected_path, on_double_click=on_double_click, on_context_menu=on_context_menu, on_select=on_select, ) return container
def _render_header() -> None: # pragma: no cover -- NiceGUI render, driven by e2e """Render the file-list column header row (Name / Size / Modified / Status).""" try: from nicegui import ui except Exception: return cell = ( "font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.06em; " "color: var(--color-muted); font-weight: 600;" ) with ( ui.element("tr") .style("border-bottom: 1px solid var(--color-rule);") .props('data-testid="file-list-header"') ): for title, align in ( ("Name", "left"), ("Size", "right"), ("Modified", "left"), ("Status", "left"), ): with ui.element("th").classes("p-2").style(f"text-align: {align}; {cell}"): ui.label(title) def _render_row( entry: FileListEntry, *, index: int, is_new: bool, is_selected: bool, on_double_click: Callable[[FileListEntry], None] | None, on_context_menu: Callable[[FileListEntry, str], None] | None, on_select: Callable[[FileListEntry], None] | None, ) -> None: # pragma: no cover -- NiceGUI render, driven by e2e try: from nicegui import ui except Exception: return size_text = "-" if entry.size_bytes is None else format_bytes(int(entry.size_bytes)) modified_text = entry.modified_iso or "-" # Row-state precedence (Selected > New > Tombstone > Zebra) plus the # dim/italic tombstone decoration are computed once by the pure resolver # (Phase 2); the border-bottom rule is the row's only constant style. state_style = row_background(entry, is_selected=is_selected, is_new=is_new, index=index) row_style = f"{state_style} border-bottom: 1px solid var(--color-rule);".strip() # The Status cell renders the two-icon (local + NAS) presence pair. # Tombstones carry their own discriminator ("on_nas" or "missing"); a # tombstone with no recorded status still reads as "on_nas". An untracked # file / folder carries no status and the pair renders nothing. view = file_sync_view(entry.sync_status or ("on_nas" if entry.tombstone else None)) keep_local_attr = ' data-keep-local="true"' if entry.keep_local else "" tombstone_attr = ' data-tombstone="true"' if entry.tombstone else "" selected_attr = ' data-selected="true"' if is_selected else "" row = ( ui.element("tr") .style(row_style) .props( f'data-testid="file-list-row" data-path="{entry.path}"' f"{keep_local_attr}{tombstone_attr}{selected_attr}" ) ) # Single-click selects the row (files AND folders); the default-arg # idiom pins ``entry`` per row so every closure captures its own row. if on_select is not None: row.on("click", lambda _evt, e=entry: on_select(e)) with row: with ( ui.element("td").classes("p-2").style("font-weight: 500;"), ui.row().classes("items-center").style("gap: 0.4rem;"), ): file_type_icon(entry.name, is_dir=entry.is_dir) ui.label(entry.name) if entry.keep_local: ui.label("kept local").props('data-testid="file-keep-local-badge"').style( "display: inline-block; margin-left: 0.4rem; padding: 0 0.35rem; " "font-size: var(--text-xs); border-radius: var(--radius-sm); " "background: var(--color-highlight); color: var(--color-muted);" ) with ui.element("td").classes("p-2 text-right"): ui.label(size_text) with ui.element("td").classes("p-2"): ui.label(modified_text) with ui.element("td").classes("p-2"): sync_pair_icons(view) if on_context_menu is not None: with ui.context_menu().props( f'data-testid="file-context-menu" data-path="{entry.path}"' ): # A tombstone has no local copy -- "Open in OS" would # fail, so it is omitted for tombstone rows. if not entry.tombstone: ui.menu_item("Open in OS").props('data-testid="file-context-open-in-os"').on( "click", lambda _evt, e=entry: on_context_menu(e, FILE_CONTEXT_OPEN), ) ui.menu_item("Copy path").props('data-testid="file-context-copy-path"').on( "click", lambda _evt, e=entry: on_context_menu(e, FILE_CONTEXT_COPY_PATH), ) keep_local_label = "Don't keep local" if entry.keep_local else "Keep local" ui.menu_item(keep_local_label).props('data-testid="file-context-keep-local"').on( "click", lambda _evt, e=entry: on_context_menu(e, FILE_CONTEXT_KEEP_LOCAL), ) if on_double_click is not None: # NiceGUI doesn't expose row-level dblclick easily; the caller is # responsible for wiring through any JS layer when needed. The # callback is kept in the signature so unit tests can verify # plumbing. pass