Source code for exlab_wizard.ui.client.folder_feed

"""Folder-feed client abstraction. GUI/Orchestrator Redesign §5.

A small ``start(path, on_update) / stop()`` surface that owns the
~2-3 s ``GET /folder/{path}`` poll for the centre-pane file list.
Returning to the same path is idempotent; switching paths stops the
previous poll before starting the new one. Paused on window background
/ foreground per §5.

The HTTP call is delegated through a caller-provided async function so
the module remains framework-agnostic (NiceGUI / pytest both reuse it).
"""

from __future__ import annotations

import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any

# Default poll cadence; the spec bounds it to ~2-3s.
FOLDER_FEED_POLL_INTERVAL_S: float = 2.5


[docs] @dataclass class FolderFeedState: """In-memory state of one folder feed (one per centre pane).""" path: str | None = None paused: bool = False last_payload: Any | None = None
[docs] class FolderFeed: """One folder feed; rebound to a different path via ``start()``.""" def __init__( self, *, fetch: Callable[[str], Awaitable[Any]], on_update: Callable[[Any], None] | None = None, poll_interval_s: float = FOLDER_FEED_POLL_INTERVAL_S, ) -> None: self._fetch = fetch self._on_update = on_update self._poll_interval_s = poll_interval_s self._state = FolderFeedState() self._task: asyncio.Task[None] | None = None @property def state(self) -> FolderFeedState: return self._state
[docs] async def start(self, path: str) -> None: """Start polling ``path``. Switching paths stops the previous poll before starting the new one (the spec's `stop()` then `start()` lifecycle, called from the single ``on_select_node`` handler).""" if self._state.path == path and self._task is not None and not self._task.done(): return await self.stop() self._state.path = path self._state.paused = False self._task = asyncio.create_task(self._loop(), name=f"exlab-folder-feed:{path}")
[docs] async def stop(self) -> None: if self._task is not None: self._task.cancel() import contextlib as _ctx with _ctx.suppress(asyncio.CancelledError, Exception): await self._task self._task = None self._state.path = None
[docs] def pause(self) -> None: """Pause polling — used when the window is backgrounded.""" self._state.paused = True
[docs] def resume(self) -> None: """Resume polling after a pause.""" self._state.paused = False
async def _loop(self) -> None: assert self._state.path is not None path = self._state.path while True: try: if not self._state.paused and self._state.path == path: payload = await self._fetch(path) self._state.last_payload = payload if self._on_update is not None: self._on_update(payload) except asyncio.CancelledError: raise except Exception: # pragma: no cover -- transient network # Per §10: keep last good state on transient failure. pass await asyncio.sleep(self._poll_interval_s)