/** * @file projectTreePrefs — the sidebar workspace list's viewer preferences, in * ONE localStorage key (`agenthub.sidebar.projects`): which workspaces are * expanded to their sessions (by lane id), the tool the list is filtered to, * which owner groups an admin/analyst folded, and — Settings → Worktrees — * whether a workspace home's Add folder suggests the repo's untracked * worktrees and which sources of them (`claude` = `/.claude/worktrees/*`, * `agenthub` = LANES_ROOT) it leaves out. A `useSyncExternalStore` reader that caches nothing * but the parsed blob for the current raw string, so the sidebar and Settings * can never disagree. Nothing here reaches the server. * @author Nguyễn Ngọc Trí Vĩ */ import { useSyncExternalStore } from "react"; export const PROJECT_TREE_KEY = "agenthub.sidebar.projects"; export interface ProjectTreePrefs { /** Lane id → its sessions are shown under it in the sidebar. */ expanded: Record; /** Show only this tool's workspaces; null = every tool. */ tool: string | null; /** Server mode, admin/analyst: owner key (user id, or "none") → its group is folded. */ collapsedOwners: Record; showUntracked: boolean; hideSources: string[]; } const DEFAULTS: ProjectTreePrefs = { expanded: {}, tool: null, collapsedOwners: {}, showUntracked: true, hideSources: [], }; const listeners = new Set<() => void>(); let cache: { raw: string | null; value: ProjectTreePrefs } = { raw: null, value: DEFAULTS }; function readRaw(): string | null { try { return localStorage.getItem(PROJECT_TREE_KEY); } catch { return null; } } export function readProjectTreePrefs(): ProjectTreePrefs { const raw = readRaw(); if (raw === cache.raw) return cache.value; let value = DEFAULTS; try { const parsed = raw ? (JSON.parse(raw) as Partial) : {}; value = { expanded: parsed.expanded && typeof parsed.expanded === "object" ? parsed.expanded : {}, tool: typeof parsed.tool === "string" && parsed.tool ? parsed.tool : null, collapsedOwners: parsed.collapsedOwners && typeof parsed.collapsedOwners === "object" ? parsed.collapsedOwners : {}, showUntracked: parsed.showUntracked !== false, hideSources: Array.isArray(parsed.hideSources) ? parsed.hideSources : [], }; } catch { value = DEFAULTS; } cache = { raw, value }; return value; } export function writeProjectTreePrefs(patch: Partial): void { try { localStorage.setItem(PROJECT_TREE_KEY, JSON.stringify({ ...readProjectTreePrefs(), ...patch })); } catch { /* disabled storage: the tree just forgets between visits */ } listeners.forEach((l) => l()); } function subscribe(l: () => void) { listeners.add(l); return () => listeners.delete(l); } export function useProjectTreePrefs(): ProjectTreePrefs { return useSyncExternalStore(subscribe, readProjectTreePrefs); }