import { useEffect, useMemo, useRef, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { ChevronRight, Folder, MessageSquare, Plus } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; import { AddProjectModal } from './AddProjectModal'; import { api } from '@/api/client'; import { sessionEvents } from '@/hooks/sessionEvents'; import { useSidebar } from '@/hooks/useSidebar'; import type { SidebarProject } from '@/api/types'; import { cn } from '@/lib/utils'; const EXPANDED_KEY = 'boocode.sidebar.expanded'; const MAX_VISIBLE_SESSIONS = 5; function readExpanded(): Set { try { const raw = localStorage.getItem(EXPANDED_KEY); if (!raw) return new Set(); const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return new Set(); return new Set(parsed.filter((v): v is string => typeof v === 'string')); } catch { return new Set(); } } function writeExpanded(ids: Set): void { try { localStorage.setItem(EXPANDED_KEY, JSON.stringify(Array.from(ids))); } catch { /* quota or disabled storage — ignore */ } } function relTime(iso: string): string { const now = Date.now(); const t = Date.parse(iso); if (Number.isNaN(t)) return ''; const sec = Math.max(0, Math.floor((now - t) / 1000)); if (sec < 60) return `${sec}s`; const min = Math.floor(sec / 60); if (min < 60) return `${min}m`; const hr = Math.floor(min / 60); if (hr < 24) return `${hr}h`; const day = Math.floor(hr / 24); if (day < 30) return `${day}d`; const mo = Math.floor(day / 30); if (mo < 12) return `${mo}mo`; return `${Math.floor(mo / 12)}y`; } function activeProjectId( pathname: string, projects: SidebarProject[], activeSession: { session_id: string; project_id: string } | null ): string | null { const pm = pathname.match(/^\/project\/([^/]+)/); if (pm?.[1]) return pm[1]; const sm = pathname.match(/^\/session\/([^/]+)/); const sid = sm?.[1]; if (!sid) return null; // Prefer the cache lookup so we resolve correctly even when an older // activeSession (from a prior route) hasn't been cleared yet. const fromCache = projects.find((p) => p.recent_sessions.some((s) => s.id === sid) )?.id; if (fromCache) return fromCache; // Fallback: the session was loaded via deep link (not in cache) and // emitted session_loaded — use that. Guard against stale values by // matching the current URL sid. if (activeSession && activeSession.session_id === sid) { return activeSession.project_id; } return null; } function activeSessionId(pathname: string): string | null { const m = pathname.match(/^\/session\/([^/]+)/); return m?.[1] ?? null; } export function ProjectSidebar() { const { data, error, loading, retry, activeSession: loadedActiveSession } = useSidebar(); const [addOpen, setAddOpen] = useState(false); const [expanded, setExpanded] = useState>(() => readExpanded()); const [renamingSession, setRenamingSession] = useState(null); const [renameValue, setRenameValue] = useState(''); const navigate = useNavigate(); const location = useLocation(); const lastToastedError = useRef(null); useEffect(() => { if (error && !data && error !== lastToastedError.current) { toast.error(error); lastToastedError.current = error; } if (!error) lastToastedError.current = null; }, [error, data]); const projects = data?.projects ?? []; const activeProject = useMemo( () => activeProjectId(location.pathname, projects, loadedActiveSession), [location.pathname, projects, loadedActiveSession] ); const activeSession = useMemo( () => activeSessionId(location.pathname), [location.pathname] ); function toggle(id: string) { setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); writeExpanded(next); return next; }); } async function handleRemove(id: string) { try { await api.projects.remove(id); sessionEvents.emit({ type: 'project_deleted', project_id: id }); navigate('/'); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to remove project'); } } async function handleArchiveSession(sessionId: string, projectId: string) { try { await api.sessions.archive(sessionId); sessionEvents.emit({ type: 'session_archived', session_id: sessionId, project_id: projectId }); if (activeSession === sessionId) navigate(`/project/${projectId}`); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to archive session'); } } async function handleRenameSession(sessionId: string) { const trimmed = renameValue.trim(); setRenamingSession(null); if (!trimmed) return; try { await api.sessions.update(sessionId, { name: trimmed }); sessionEvents.emit({ type: 'session_renamed', session_id: sessionId, name: trimmed }); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to rename session'); } } const rowCls = (active: boolean) => active ? 'bg-sidebar-accent text-sidebar-accent-foreground' : 'hover:bg-sidebar-accent/60'; return ( ); }