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