import { useEffect, useState } from 'react'; import { api } from '@/api/client'; import type { SidebarProject, SidebarResponse, SidebarSession } from '@/api/types'; import { sessionEvents } from './sessionEvents'; const RECENT_SESSIONS_LIMIT = 6; // Module-scope shared state — there is at most one sidebar fetch // for the lifetime of the page, regardless of how many components // call useSidebar(). let sharedData: SidebarResponse | null = null; let sharedError: string | null = null; let sharedLoading: boolean = true; let initialized = false; let fetchInFlight: Promise | null = null; let activeSession: { session_id: string; project_id: string } | null = null; const subscribers = new Set<() => void>(); function notify(): void { for (const sub of subscribers) { try { sub(); } catch { // swallow — one bad subscriber shouldn't break others } } } function load(): Promise { if (fetchInFlight) return fetchInFlight; sharedLoading = true; sharedError = null; notify(); const p = (async () => { try { const res = await api.sidebar.get(); sharedData = res; sharedError = null; } catch (err) { sharedData = null; sharedError = err instanceof Error ? err.message : 'failed to load sidebar'; } finally { sharedLoading = false; fetchInFlight = null; notify(); } })(); fetchInFlight = p; return p; } function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').SessionEvent): SidebarResponse { switch (event.type) { case 'project_created': { if (prev.projects.some((p) => p.id === event.project.id)) return prev; const fresh: SidebarProject = { id: event.project.id, name: event.project.name, path: event.project.path, gitea_remote: event.project.gitea_remote ?? null, recent_sessions: [], total_sessions: 0, }; return { ...prev, projects: [fresh, ...prev.projects] }; } case 'project_deleted': { const next = prev.projects.filter((p) => p.id !== event.project_id); if (next.length === prev.projects.length) return prev; return { ...prev, projects: next }; } case 'session_created': { let changed = false; const projects = prev.projects.map((p) => { if (p.id !== event.project_id) return p; if (p.recent_sessions.some((s) => s.id === event.session.id)) return p; changed = true; const fresh: SidebarSession = { id: event.session.id, name: event.session.name, model: event.session.model, updated_at: event.session.updated_at, project_id: event.project_id, }; return { ...p, recent_sessions: [fresh, ...p.recent_sessions].slice(0, RECENT_SESSIONS_LIMIT), total_sessions: p.total_sessions + 1, }; }); return changed ? { ...prev, projects } : prev; } case 'session_deleted': { let changed = false; const projects = prev.projects.map((p) => { if (p.id !== event.project_id) return p; const recent = p.recent_sessions.filter((s) => s.id !== event.session_id); const wasPresent = recent.length !== p.recent_sessions.length; if (!wasPresent) return p; changed = true; return { ...p, recent_sessions: recent, total_sessions: Math.max(0, p.total_sessions - 1), }; }); return changed ? { ...prev, projects } : prev; } case 'session_renamed': { let changed = false; const projects = prev.projects.map((p) => { let projectChanged = false; const recent = p.recent_sessions.map((s) => { if (s.id !== event.session_id) return s; if (s.name === event.name) return s; projectChanged = true; return { ...s, name: event.name }; }); if (!projectChanged) return p; changed = true; return { ...p, recent_sessions: recent }; }); return changed ? { ...prev, projects } : prev; } case 'session_updated': { let changed = false; const projects = prev.projects.map((p) => { if (p.id !== event.project_id) return p; let projectChanged = false; const recent = p.recent_sessions.map((s) => { if (s.id !== event.session_id) return s; projectChanged = true; return { ...s, name: event.name, updated_at: event.updated_at }; }); if (!projectChanged) return p; changed = true; const sorted = [...recent].sort( (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() ); return { ...p, recent_sessions: sorted }; }); return changed ? { ...prev, projects } : prev; } case 'session_loaded': // activeSessionProjectId is updated in the subscribe callback; no data change here. return prev; case 'session_workspace_updated': // Pane layout is consumed by useWorkspacePanes; sidebar has no stake. return prev; case 'open_file_in_browser': // Consumed by Workspace (T7); no sidebar state change needed. return prev; case 'attach_chat_file': return prev; case 'open_chat_in_active_pane': // Consumed by Workspace; sidebar has no business with pane state. return prev; case 'open_settings_pane': // Consumed by Session.tsx (calls toggleSettingsPane on its panesHook). // Sidebar data is untouched. return prev; case 'session_archived': { let changed = false; const projects = prev.projects.map((p) => { if (p.id !== event.project_id) return p; const recent = p.recent_sessions.filter((s) => s.id !== event.session_id); if (recent.length === p.recent_sessions.length) return p; changed = true; return { ...p, recent_sessions: recent, total_sessions: Math.max(0, p.total_sessions - 1), }; }); return changed ? { ...prev, projects } : prev; } case 'chat_created': case 'chat_updated': case 'chat_archived': case 'chat_unarchived': case 'chat_deleted': case 'chat_status': return prev; case 'project_archived': { const next = prev.projects.filter((p) => p.id !== event.project_id); if (next.length === prev.projects.length) return prev; return { ...prev, projects: next }; } case 'project_unarchived': { if (prev.projects.some((p) => p.id === event.project.id)) return prev; const fresh: SidebarProject = { id: event.project.id, name: event.project.name, path: event.project.path, gitea_remote: event.project.gitea_remote ?? null, recent_sessions: [], total_sessions: 0, }; return { ...prev, projects: [fresh, ...prev.projects] }; } case 'project_updated': { let changed = false; const projects = prev.projects.map((p) => { if (p.id !== event.project_id) return p; if (p.name === event.name) return p; changed = true; return { ...p, name: event.name }; }); return changed ? { ...prev, projects } : prev; } } } // One bus subscription for the lifetime of the module. Events arriving // before the initial fetch resolves are dropped; the eventual fetch // result is the source of truth. // Guard prevents duplicate listeners during Vite HMR reloads. const G = globalThis as Record; if (!G.__boocode_sidebar_subscribed) { G.__boocode_sidebar_subscribed = true; sessionEvents.subscribe((event) => { if (event.type === 'session_loaded') { activeSession = { session_id: event.session_id, project_id: event.project_id }; notify(); return; } if (!sharedData) return; const next = applyEvent(sharedData, event); if (next === sharedData) return; sharedData = next; notify(); }); } interface Snapshot { data: SidebarResponse | null; error: string | null; loading: boolean; activeSession: { session_id: string; project_id: string } | null; } function snapshot(): Snapshot { return { data: sharedData, error: sharedError, loading: sharedLoading, activeSession }; } export function useSidebar(): { data: SidebarResponse | null; error: string | null; loading: boolean; retry: () => void; activeSession: { session_id: string; project_id: string } | null; } { const [state, setState] = useState(snapshot); useEffect(() => { const sub = () => setState(snapshot()); subscribers.add(sub); // Sync up if the module state changed between render and effect. sub(); if (!initialized) { initialized = true; void load(); } return () => { subscribers.delete(sub); }; }, []); const retry = () => { void load(); }; return { data: state.data, error: state.error, loading: state.loading, retry, activeSession: state.activeSession }; }