"""Main window (Frontend Spec §3).
Layout:
* **Left drawer** -- search box + filter chips + tree.
* **Right pane** -- tabs (Details / Problems) + detail / problems view.
* **Header toolbar** -- New Project / New Run / New Test Run / Settings /
Refresh.
* **Bottom status bar** -- Sync / Validator / LIMS segments.
Pre-Phase-13 the tray and window subprocess plumbing isn't wired here;
this page is invoked from the FastAPI app's NiceGUI mount point
(``GET /``).
"""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from exlab_wizard.logging import get_logger
from exlab_wizard.ui import notifications
from exlab_wizard.ui.components import banner_stack, filter_chips, status_bar_segment
from exlab_wizard.ui.components.empty_state import empty_state
from exlab_wizard.ui.components.framed_pane import card_style, framed_pane
from exlab_wizard.ui.components.tree import TreeFilters, TreeNode, build_nodes, build_tree
from exlab_wizard.ui.pages.staging import StagingDockState
_log = get_logger(__name__)
[docs]
@dataclass
class MainPageState:
"""Render state for the main page.
GUI/Orchestrator Redesign §4: the legacy two-tab Details / Problems
layout coexists with a new three-region file-explorer renderer
(``render_file_explorer_page``); the v1 fields stay so existing
flows keep working until Phase 9 / 10 retire them.
"""
setup_incomplete: bool = False
# §4.9.3 next-action discriminator, used to tailor the setup-incomplete
# banner subline (rclone-only NAS migration, 2026-05-26).
setup_next_action: str | None = None
selected_node: str | None = None
chip_state: filter_chips.ChipState = field(
default_factory=lambda: filter_chips.initial_state(_default_chips())
)
active_tab: str = "details" # "details" | "problems"
problems_count_hard: int = 0
problems_count_soft: int = 0
# In-flight controller operations (Frontend §9.5). ``operations_count``
# gates the toolbar [Operations…] button; ``operations_input_required``
# drives the footer Sync segment's "N need input" warning.
operations_count: int = 0
operations_input_required: int = 0
# §9.6 single-equipment concurrency: the three creation buttons are
# disabled while any session is mid-flight (non-terminal).
creation_in_flight: bool = False
# Legacy field — the orchestrator pipeline is always active under
# Redesign §3.1, so this always renders True in production.
orchestrator_enabled: bool = True
staging_dock: StagingDockState | None = None
# Redesign §4 file-explorer additions:
right_pane_collapsed: bool = False
folder_feed_path: str | None = None
selected_node_kind: str | None = None
expand_state: dict[str, bool] = field(default_factory=dict)
selected_node_is_received: bool = False
"""True when the selected tree node is received equipment (decision 1):
the three creation buttons (New Project / New Run / New Test Run) are
disabled while this is True."""
# Redesign §4.3/§4.4 (Phase 4, Option B): a file or folder selected in the
# centre list. ``selected_file_path`` highlights the row; ``selected_file``
# is a render-ready payload assembled by the mount layer (no new fetch for
# files -- resolved from the in-memory feed; a one-level scan for folders).
# Shape: {"kind": "file"|"folder", "name", "path", ...}; see
# metadata_pane.render_selected_file_card.
selected_file_path: str | None = None
selected_file: dict[str, Any] | None = None
# §4.9 search box -> tree filter; §4.8 file-list row density ("" =
# comfortable, "compact"). Both ride the URL (?q=, ?density=) per OQ-1/A.
search_query: str = ""
density: str = ""
# Footer status-segment states (Phase 5 / §3.5.5), derived from live
# backend signals by the mount layer
# (status_bar_segment.derive_footer_segment_states). Default to NORMAL so a
# half-wired backend (or a direct render in tests) shows a calm footer.
validator_state: str = status_bar_segment.SEGMENT_NORMAL
lims_state: str = status_bar_segment.SEGMENT_NORMAL
staging_state: str = status_bar_segment.SEGMENT_NORMAL
def _default_chips() -> tuple[filter_chips.ChipDefinition, ...]:
"""The three default chips on the left-tree filter strip (§3.5.4)."""
return (
filter_chips.ChipDefinition(chip_id="active", label="Active", default_on=True),
filter_chips.ChipDefinition(chip_id="archived", label="Archived", default_on=False),
filter_chips.ChipDefinition(chip_id="test_runs", label="Test runs", default_on=True),
)
[docs]
def chip_state_to_tree_filters(state: filter_chips.ChipState, search: str = "") -> TreeFilters:
"""Translate a chip group state into a :class:`TreeFilters`."""
return TreeFilters(
active=filter_chips.is_active(state, "active"),
archived=filter_chips.is_active(state, "archived"),
test_runs=filter_chips.is_active(state, "test_runs"),
search=search,
)
[docs]
def count_search_results(nodes: list[TreeNode]) -> int:
"""Count the project + run rows surfaced under the equipment roots.
:func:`build_nodes` always emits every equipment row -- even one with no
matching children -- so the equipment tally is not a useful "did the
search find anything" signal. This sums the project + run rows instead:
the number the search-result pill shows, and whose ``0`` value drives the
no-matches state (Phase 5 / OQ-2). Pure so it is testable without NiceGUI.
"""
total = 0
for equipment in nodes:
for project in equipment.children:
total += 1 + len(project.children)
return total
DENSITY_COMPACT = "compact"
_DENSITY_COMPACT_CLASS = "exlab-density-compact"
[docs]
def density_card_class(density: str) -> str:
"""Return the Files-card CSS class for the current row density (§4.8).
``"compact"`` -> ``"exlab-density-compact"`` (the theme rule tightens the
file-row vertical padding to ``--sp-1``); anything else -> ``""`` (the
default comfortable ``--sp-2`` inherited from Tailwind ``.p-2``). Pure so
it is testable without NiceGUI.
"""
return _DENSITY_COMPACT_CLASS if density == DENSITY_COMPACT else ""
[docs]
def problems_badge_text(state: MainPageState) -> str:
"""Return the count text shown on the Problems tab.
Frontend §3.2: hard count primary, soft count secondary
(e.g. ``3 + 12``).
"""
if state.problems_count_soft == 0:
return str(state.problems_count_hard)
return f"{state.problems_count_hard} + {state.problems_count_soft}"
[docs]
def setup_incomplete_banner_props(next_action: str | None = None) -> dict[str, str]:
"""Banner content for the setup-incomplete state (§3.1.4).
``next_action`` is the §4.9.3 next-action discriminator (e.g.
``configure_rclone_remote``). When supplied it tailors the subline so
the operator knows exactly which section to open; the rclone-only
NAS migration (2026-05-26) added the rclone-remote variant.
"""
sublines = {
"configure_rclone_remote": ("Configure the rclone remote (see setup docs) to begin."),
"configure_lims": "Open Settings → LIMS to finish configuring LIMS.",
"test_lims": "Open Settings → LIMS to finish configuring LIMS.",
"set_paths": "Open Settings and complete the highlighted sections to begin.",
"add_equipment": "Add an equipment in Settings to begin.",
}
return {
"headline": "Setup incomplete: required configuration is missing.",
"subline": sublines.get(
next_action or "",
"Open Settings and complete the highlighted sections to begin.",
),
"cta_label": "Open Settings",
"color_var": "--color-warning",
}
[docs]
def render_file_explorer_page(
*,
on_open_new_project: Callable[[], None],
on_open_new_run: Callable[[], None],
on_open_new_test_run: Callable[[], None],
on_open_add_equipment: Callable[[], None],
on_open_settings: Callable[[], None],
on_refresh: Callable[[], None],
on_select_node: Callable[[str], None],
on_open_operations: Callable[[], None] | None = None,
on_navigate_breadcrumb: Callable[[str], None] | None = None,
on_toggle_right_pane: Callable[[], None] | None = None,
on_run_staging_action: Callable[[str, str], None] | None = None,
on_tree_context_action: Callable[[str, str], None] | None = None,
on_file_context_action: Callable[[Any, str], None] | None = None,
on_select_file: Callable[[Any], None] | None = None,
on_refresh_folder: Callable[[], None] | None = None,
on_search: Callable[[str], None] | None = None,
on_toggle_density: Callable[[], None] | None = None,
state: MainPageState | None = None,
hierarchy: dict | None = None,
file_list_entries: list[Any] | None = None,
metadata_payload: dict[str, Any] | None = None,
tree_expand_all: bool = False,
) -> Any: # pragma: no cover -- NiceGUI render, driven by e2e
"""Render the rebuilt three-region file-explorer main window.
GUI/Orchestrator Redesign §4. Header toolbar + breadcrumb + splitter
(tree | live file list | metadata/problems pane) + footer status
bar. The render function stays free of session-store / API deps:
state is injected; the caller wires the callbacks.
``file_list_entries`` and ``metadata_payload`` carry the data the
centre and right panes display when a node is selected; both are
optional so unit tests can render the empty-state. The mount layer
sources them from the live FolderFeed payload and the per-node
metadata builder respectively.
``tree_expand_all`` forwards to :func:`build_tree`'s ``expand_all``
kwarg -- only set by e2e tests that need every node visible in the
DOM up front.
"""
s = state or MainPageState()
try:
from nicegui import ui
except Exception:
return {"state": s}
from exlab_wizard.ui.components.breadcrumb import render_breadcrumb
# Pin the page to the viewport so the three panes fill the height between
# the fixed header and footer and scroll *internally* rather than growing
# the page. NiceGUI's q-layout / q-page chain is min-height-driven (content-
# sized) by default; these page-scoped overrides give it a definite height
# and flex-fill down to the splitter, whose panes then scroll within their
# own overflow:auto bodies.
ui.query(".q-page-container").style(
"height: 100vh; display: flex; flex-direction: column; overflow: hidden;"
)
ui.query(".q-page").style("flex: 1 1 0; min-height: 0; display: flex; flex-direction: column;")
ui.query(".nicegui-content").style("flex: 1 1 0; min-height: 0;")
with (
ui.header()
.classes("items-center")
.style(
"background: var(--color-surface); "
"border-bottom: 1px solid var(--color-rule); "
"padding: var(--sp-3) var(--sp-6);"
)
):
ui.label("ExLab-Wizard").style(
"font-family: var(--font-display); font-size: var(--text-md); "
"color: var(--color-heading); font-weight: 600;"
)
ui.space()
# Redesign decision 1: creation buttons disable on received-equipment.
np_btn = ui.button("New Project", on_click=lambda _evt: on_open_new_project()).props(
'color=primary data-testid="toolbar-new-project"'
)
nr_btn = ui.button("New Run", on_click=lambda _evt: on_open_new_run()).props(
'color=primary data-testid="toolbar-new-run"'
)
ntr_btn = ui.button("New Test Run", on_click=lambda _evt: on_open_new_test_run()).props(
'color=warning data-testid="toolbar-new-test-run"'
)
# Creation is disabled on received-equipment nodes (decision 1) and
# while any session is mid-flight (single-equipment concurrency, §9.6).
if s.selected_node_is_received or s.creation_in_flight:
for btn in (np_btn, nr_btn, ntr_btn):
btn.props("disable")
if s.creation_in_flight and not s.selected_node_is_received:
np_btn.tooltip("A creation is already in progress")
ui.button("Add Equipment", on_click=lambda _evt: on_open_add_equipment()).props(
'color=primary data-testid="toolbar-add-equipment"'
)
# Group divider: the creation actions (New Project / New Run / New Test
# Run / Add Equipment) sit left of this rule; the utility actions
# (Operations / Refresh / Settings) sit right of it, so the toolbar
# reads as two groups. Every button's order + testid is unchanged; the
# margin supplies the inter-group gap.
ui.separator().props('vertical data-testid="toolbar-group-divider"').style(
"height: 1.5rem; margin: 0 var(--sp-2, 0.5rem); background: var(--color-rule, #e8ecf2);"
)
# [Operations…] surfaces only while ≥1 operation is in flight
# (Frontend §9.5). Label carries the count; a warning color flags
# any suspended (INPUT_REQUIRED) session needing an answer.
if on_open_operations is not None and s.operations_count > 0:
ops_color = "warning" if s.operations_input_required > 0 else "primary"
ui.button(
f"Operations ({s.operations_count})",
on_click=lambda _evt: on_open_operations(),
).props(f'flat color={ops_color} data-testid="toolbar-operations"')
ui.button("Refresh", on_click=lambda _evt: on_refresh()).props(
'flat data-testid="toolbar-refresh"'
)
ui.button("Settings", on_click=lambda _evt: on_open_settings()).props(
'flat data-testid="toolbar-settings"'
)
render_breadcrumb(
selected_node=s.selected_node,
on_navigate=on_navigate_breadcrumb,
)
if s.setup_incomplete:
notifications.show_banner(
notifications.BannerId.SETUP_INCOMPLETE,
container=notifications.ContainerId.GLOBAL,
severity=notifications.Severity.WARNING,
message=setup_incomplete_banner_props(s.setup_next_action)["subline"],
action=notifications.ActionSpec(
label="Open Settings",
on_click=on_open_settings,
),
dismissible=False,
)
else:
notifications.clear_banner(notifications.BannerId.SETUP_INCOMPLETE)
banner_stack.banner_stack(notifications.ContainerId.GLOBAL)
# Run-row context-menu items share the metadata-pane's
# (path, action) callback signature, so the tree's run-context
# actions route into on_run_staging_action via this thin shim.
def _route_run_context(node_id: str, action: str) -> None:
if on_run_staging_action is not None:
on_run_staging_action(node_id, action)
# Splitter holds tree | (file list + metadata pane). The right-pane
# collapse toggle is wired by the caller via on_toggle_right_pane.
with (
ui.splitter(value=20)
.classes("w-full")
.style("flex: 1 1 0; min-height: 0; gap: var(--sp-3, 0.75rem);")
) as outer_split:
with (
outer_split.before,
framed_pane("Explorer", testid="explorer-pane"),
ui.column().classes("w-full").style("gap: 0.5rem;"),
):
tree_filters = chip_state_to_tree_filters(s.chip_state, search=s.search_query)
# Search box (§4.9 / OQ-2): the clear affordance (clearable) wipes
# it; Quasar's `debounce` coalesces keystrokes so the page re-navigates
# (?q=) once the operator pauses -- the same URL/navigate model the
# filter chips and row selection already use. The narrowed local
# keeps mypy happy about the optional callback inside the closure.
search_cb = on_search
def _on_search_change(event: Any) -> None:
if search_cb is None:
return
search_cb((event.value or "").strip())
ui.input(
label="Search",
value=s.search_query,
on_change=_on_search_change if on_search is not None else None,
).props('data-testid="main-search" clearable debounce=300').style("width: 100%;")
# Result count / no-matches affordance -- shown only while a query
# is active, sourced from the same build_nodes the tree renders so
# the tally can't drift from what's on screen.
if s.search_query:
_matches = count_search_results(
build_nodes(hierarchy=hierarchy or {}, filters=tree_filters)
)
_search_hint_style = (
"color: var(--color-muted); font-size: var(--text-xs); padding: 0 var(--sp-1);"
)
if _matches == 0:
ui.label("No matches.").props('data-testid="main-search-no-matches"').style(
_search_hint_style
)
else:
ui.label(f"{_matches} result{'s' if _matches != 1 else ''}").props(
'data-testid="main-search-count"'
).style(_search_hint_style)
filter_chips.filter_chips(_default_chips(), state=s.chip_state)
build_tree(
hierarchy=hierarchy or {},
filters=tree_filters,
on_select=on_select_node,
on_equipment_context_action=on_tree_context_action,
on_run_context_action=(
_route_run_context if on_run_staging_action is not None else None
),
expand_all=tree_expand_all,
selected_node=s.selected_node,
)
# The Files pane fills the splitter's right side; the metadata pane
# floats over its right edge as an overlay popover (no permanent docked
# column). The vertical "Metadata" tab toggles it via the right_pane URL
# param. position:relative anchors the absolutely-positioned popover+tab.
with (
outer_split.after,
ui.element("div")
.classes("w-full h-full")
.style(
"position: relative; display: flex; flex-direction: row; "
"flex-wrap: nowrap; align-items: stretch;"
),
):
with ui.element("div").style("flex: 1 1 auto; min-width: 0; height: 100%;"):
files_count = f"{len(file_list_entries)} items" if file_list_entries else None
def _files_header_extra() -> None:
# Files-header controls, grouped left beside the count pill
# (count_left=True): per-folder refresh, the row-density
# toggle, and the sync-status legend.
refresh = on_refresh_folder
if refresh is not None:
# OQ-1/A mitigation: re-scans only the open folder then
# re-renders (mount._refresh_selected_folder) -- distinct
# from the toolbar's "Refresh everything".
ui.button(icon="refresh", on_click=lambda _evt: refresh()).props(
'flat dense round size=sm data-testid="files-refresh" '
'title="Refresh this folder"'
).style("color: var(--color-muted, #8892a4);")
toggle_density = on_toggle_density
if toggle_density is not None:
# Row density (§4.8): compact <-> comfortable, rides ?density=.
is_compact = s.density == DENSITY_COMPACT
ui.button(
icon="density_large" if is_compact else "density_small",
on_click=lambda _evt, cb=toggle_density: cb(),
).props(
'flat dense round size=sm data-testid="files-density-toggle" '
f'title="{"Comfortable rows" if is_compact else "Compact rows"}"'
).style("color: var(--color-muted, #8892a4);")
_render_sync_legend()
with framed_pane(
"Files",
count=files_count,
testid="files-pane",
header_extra=_files_header_extra,
count_left=True,
card_classes=density_card_class(s.density),
):
_render_centre_file_list(
s,
file_list_entries=file_list_entries,
on_file_context_action=on_file_context_action,
on_select_file=on_select_file,
)
# Vertical "Metadata" tab: a chevron above a vertical label in a
# raised box. Absolutely positioned -- on the popover's left edge
# when open (overlapping it, painted just behind so the right half
# tucks under the panel), or parked at the container's right edge
# when the popover is closed.
if on_toggle_right_pane is not None:
collapsed = s.right_pane_collapsed
chevron = "◀" if collapsed else "▶"
tab_label = "Metadata"
tab_pos = (
"right: 0; z-index: 21;"
if collapsed
else "right: calc(40% - 16px); z-index: 19;"
)
toggle = (
ui.button(on_click=lambda _evt: on_toggle_right_pane())
.props(
'flat dense no-caps data-testid="toggle-right-pane" '
'aria-label="Toggle metadata pane" title="Toggle metadata pane"'
)
.style(
# Absolute so it anchors to the popover's left edge (open)
# or the container's right edge (closed). Theme vars keep
# literal fallbacks for robustness.
f"position: absolute; top: 64px; {tab_pos} "
"min-width: 0; width: 40px; height: 190px; padding: 0; "
"color: var(--color-muted, #8892a4);"
)
)
with (
toggle,
ui.column().style(
# Content hugs the left edge so the chevron + vertical
# label stay on the visible left half (the right half is
# tucked behind the metadata pane). Only the left corners
# are rounded so the right edge reads as merging into the
# pane.
"align-items: flex-start; gap: 4px; flex-wrap: nowrap; "
"height: 100%; width: 100%; padding: 8px 3px; "
"background: var(--color-surface, #ffffff); "
"border: 1px solid var(--color-border, #dde3ed); "
"border-radius: 6px 0 0 6px; "
"box-shadow: 0 1px 3px rgba(0, 54, 96, 0.12);"
),
):
ui.label(chevron).style(
"flex: 0 0 auto; font-size: 12px; line-height: 1; "
"color: var(--color-muted, #8892a4);"
)
# Vertical label via writing-mode (robust -- no transform-box
# clipping): vertical-rl + rotate(180deg) reads bottom-to-top,
# matching the prior orientation. The column's
# align-items:flex-start keeps it on the visible left half.
ui.label(tab_label).style(
"writing-mode: vertical-rl; transform: rotate(180deg); "
"white-space: nowrap; font-size: 11px; letter-spacing: 0.05em; "
"text-transform: none; color: var(--color-muted, #8892a4);"
)
if not s.right_pane_collapsed:
# Metadata floats as an overlay popover over the right of the
# Files pane (z-index above it, strong left shadow so it reads as
# raised). It keeps its own tabs in place of a title strip;
# card_style() ends with ';' so the appended overrides
# concatenate to valid CSS.
with (
ui.element("div")
.props('data-testid="metadata-pane-card"')
.style(
"position: absolute; top: 0; right: 0; height: 100%; "
"width: 40%; z-index: 20; min-width: 0; "
f"{card_style()} overflow: auto; "
"box-shadow: -4px 0 16px rgba(0, 54, 96, 0.15);"
)
):
_render_right_pane(
s,
metadata_payload=metadata_payload,
on_run_staging_action=on_run_staging_action,
)
if not s.setup_incomplete:
with (
# Footer reads as a framed bar to match the panes: surface fill,
# hairline border, soft shadow, small margins so it sits as a
# distinct card rather than bleeding to the window edges.
ui.footer().style(
"background: var(--color-surface, #ffffff); "
"border: 1px solid var(--color-border, #dde3ed); "
"border-radius: var(--radius-md, 10px); "
"box-shadow: var(--shadow-sm, 0 1px 3px rgba(0,54,96,0.07)); "
"margin: var(--sp-2, 0.5rem); "
"padding: 0 var(--sp-4, 1rem); min-height: 24px;"
),
ui.row().classes("items-center w-full"),
):
# Sync segment doubles as the Operations entry point: when any
# session is suspended awaiting input it flips to a warning
# "N operations need input" and opens the same modal (§3.5.5).
if s.operations_input_required > 0:
status_bar_segment.status_bar_segment(
label=f"{s.operations_input_required} operations need input",
state=status_bar_segment.SEGMENT_WARNING,
on_click=on_open_operations,
)
else:
# Only clickable when there is something to show, so a click
# never opens an empty Operations panel.
status_bar_segment.status_bar_segment(
label="Sync",
state=status_bar_segment.SEGMENT_NORMAL,
on_click=on_open_operations if s.operations_count > 0 else None,
)
# Validator / LIMS / Staging states are derived from live backend
# signals by the mount (derive_footer_segment_states): Validator
# warns on a hard finding, LIMS goes danger when the endpoint is
# unreachable. They default to NORMAL on a half-wired backend.
status_bar_segment.status_bar_segment(
label="Validator",
state=s.validator_state,
)
status_bar_segment.status_bar_segment(
label="LIMS",
state=s.lims_state,
)
# Footer "Staging" segment + bulk clear-verified are intentionally
# omitted: orchestrator/staging is hidden at the UI layer (see
# docs/superpowers/specs/2026-05-29-hide-orchestrator-staging-design.md).
# ``MainPageState.staging_state`` stays as an inert field.
def _render_sync_legend() -> None: # pragma: no cover -- NiceGUI render, driven by e2e
"""Render the Files-header sync-status legend ("?") popover.
Lists each visible sync view as a real two-icon (local + NAS) swatch +
its meaning, sourced from
:func:`sync_status_icon.sync_legend_entries` so the legend renders the
same icon pairs the file list / metadata pane actually draw and can't
drift from them.
"""
try:
from nicegui import ui
except Exception:
return
from exlab_wizard.ui.components.sync_status_icon import (
FileSyncView,
sync_legend_entries,
sync_pair_icons,
)
with (
ui.button(icon="help_outline")
.props('flat dense round size=sm data-testid="files-legend" title="Sync status legend"')
.style("color: var(--color-muted, #8892a4);"),
ui.menu()
.props('data-testid="files-legend-menu"')
.style("padding: var(--sp-1, 0.25rem) 0;"),
):
ui.label("Sync status").style(
"font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.06em; "
"color: var(--color-muted); font-weight: 600; padding: var(--sp-1) var(--sp-3);"
)
for entry in sync_legend_entries():
with (
ui.row()
.classes("items-center")
.style(
"gap: var(--sp-2, 0.5rem); padding: var(--sp-1) var(--sp-3); flex-wrap: nowrap;"
)
):
sync_pair_icons(FileSyncView(entry["view"]))
ui.label(entry["tooltip"]).style("font-size: var(--text-sm); white-space: nowrap;")
def _render_centre_file_list(
state: MainPageState,
*,
file_list_entries: list[Any] | None = None,
on_file_context_action: Callable[[Any, str], None] | None = None,
on_select_file: Callable[[Any], None] | None = None,
) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
"""Render the centre-pane file list (Redesign §4.3).
Each row carries a right-click context menu (*Open in OS* /
*Copy path*) when ``on_file_context_action`` is wired. Single-click
selection (``on_select_file``) drives the right-pane metadata sub-card
and highlights the selected row (Phase 4 / Option B).
"""
from exlab_wizard.ui.components.file_list import (
FileListState,
render_file_list,
)
# No NiceGUI guard here: empty_state() and render_file_list() each no-op
# outside an app context, and FileListState is a plain dataclass, so the
# function degrades safely without an explicit ``ui`` import.
if state.folder_feed_path is None:
empty_state(
icon="account_tree",
message="Select a folder in the tree to see its contents.",
testid="file-list-empty",
)
return
fl_state = FileListState(
path=state.folder_feed_path,
entries=list(file_list_entries or []),
selected_path=state.selected_file_path,
)
render_file_list(
state=fl_state,
on_context_menu=on_file_context_action,
on_select=on_select_file,
)
def _render_right_pane(
state: MainPageState,
*,
on_run_staging_action: Callable[[str, str], None] | None = None,
metadata_payload: dict[str, Any] | None = None,
) -> None: # pragma: no cover -- NiceGUI render, driven by e2e
"""Render the right Metadata / Problems pane (Redesign §4.4)."""
from exlab_wizard.ui.components.metadata_pane import (
MetadataPaneState,
render_metadata_pane,
)
try:
from nicegui import ui
except Exception:
return
with ui.tabs() as tabs:
ui.tab("metadata", "Metadata").props('data-testid="tab-metadata"')
ui.tab(
"problems",
f"Problems ({problems_badge_text(state)})",
).props('data-testid="tab-problems"')
with ui.tab_panels(tabs, value="metadata").classes("w-full"):
with ui.tab_panel("metadata"):
mp_state = MetadataPaneState(
selected_node=state.selected_node,
node_kind=state.selected_node_kind,
payload=dict(metadata_payload or {}),
selected_file=state.selected_file,
)
render_metadata_pane(
state=mp_state,
on_run_staging_action=on_run_staging_action,
)
with ui.tab_panel("problems"):
total = state.problems_count_hard + state.problems_count_soft
ui.label(
f"{total} findings ({state.problems_count_hard} hard, "
f"{state.problems_count_soft} soft)",
).props('data-testid="problems-summary"').style(
"font-family: var(--font-mono); color: var(--color-muted);"
)