/** * @file useLanes — the lane list (GET /api/lanes) shared by every component * that shows workspaces: the sidebar's project tree, the Workspace page and the * status bar's counts. One module-level copy, so N components mean one request; * `lane_update` frames are merged in place (a `removed` or unknown lane * refetches), and a WebSocket reconnect refetches the whole list because the * frames missed while disconnected are never replayed. The copy is dropped * when the last component unmounts, so a fresh mount always reads fresh rows. * `useWorkLanes()` is the same list narrowed to the workspaces this user may * work in (server mode: an analyst's own), for the Workspace page; the sidebar * tree lists them all, other users' read only. * @author Nguyễn Ngọc Trí Vĩ */ import { useEffect, useMemo, useSyncExternalStore } from "react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { keepIfSame } from "../lib/keepIfSame"; import type { Lane, WSMessage } from "../lib/types"; import { useAuth } from "../components/AuthGate"; let lanes: Lane[] | null = null; // Bumped when the store is torn down, so a reply to a request made before the // teardown cannot repopulate it. let generation = 0; let inflight: Promise | null = null; const listeners = new Set<() => void>(); let detach: (() => void) | null = null; const EMPTY: Lane[] = []; function publish(next: Lane[]) { const kept = keepIfSame(next)(lanes); if (kept === lanes) return; lanes = kept; listeners.forEach((l) => l()); } function load(): Promise { if (!inflight) { const gen = generation; inflight = api.lanes .list() .then((r) => { if (gen === generation) publish(r.lanes); }) // Unreachable server: keep what we had. .catch(() => undefined) .finally(() => { if (gen === generation) inflight = null; }); } return inflight; } function onMessage(msg: WSMessage) { if (msg.type !== "lane_update") return; const payload = msg.data as { lane?: Lane; removed?: number }; const lane = payload.lane; if (payload.removed !== undefined || !lane || !lanes) return void load(); const i = lanes.findIndex((l) => l.id === lane.id); if (i === -1) return void load(); const next = [...lanes]; next[i] = lane; publish(next); } function subscribe(l: () => void) { listeners.add(l); if (listeners.size === 1) { const offMsg = eventBus.subscribe(onMessage); const offConn = eventBus.onConnection((up) => { if (up) void load(); }); detach = () => { offMsg(); offConn(); }; } return () => { listeners.delete(l); if (listeners.size === 0) { detach?.(); detach = null; lanes = null; inflight = null; generation++; } }; } /** Every lane, in id order; `refresh` re-reads them (after a create/remove). */ export function useLanes() { const v = useSyncExternalStore(subscribe, () => lanes); useEffect(() => { if (!lanes) void load(); }, []); return { lanes: v ?? EMPTY, loaded: v !== null, refresh: load }; } /** * The workspaces this user WORKS in — the Workspace page. * Server mode: an analyst gets every workspace from the API (to read) but may * change only their own, so only those are listed here; other users' work is * read in the sidebar tree (read only), and on Sessions, Analytics and Users. */ export function useWorkLanes() { const all = useLanes(); const { state } = useAuth(); const analystId = state.mode === "server" && state.user?.role === "analyst" ? state.user.id : null; const lanes = useMemo( () => (analystId == null ? all.lanes : all.lanes.filter((l) => l.owner_id === analystId)), [all.lanes, analystId] ); return { ...all, lanes }; }