import { useEffect, useMemo, useRef, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from '@/components/ui/context-menu'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, } from '@/components/ui/dialog'; import { AddProjectModal } from './AddProjectModal'; import { api } from '@/api/client'; import { useSidebar } from '@/hooks/useSidebar'; import { useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { useViewport } from '@/hooks/useViewport'; import { usePullToRefresh } from '@/hooks/usePullToRefresh'; import type { SidebarProject } from '@/api/types'; import { giteaUrlFor } from '@/lib/projectUrls'; 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 [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null); const [renamingProject, setRenamingProject] = useState(null); const [renameProjectValue, setRenameProjectValue] = useState(''); const [archiveProjectConfirm, setArchiveProjectConfirm] = useState<{ id: string; name: string } | null>(null); 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 handleArchiveProject(id: string) { try { await api.projects.archive(id); // Server publishes project_archived via WS. if (activeProject === id) navigate('/'); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to archive project'); } } async function handleRenameProject(id: string) { const trimmed = renameProjectValue.trim(); setRenamingProject(null); if (!trimmed) return; try { await api.projects.update(id, { name: trimmed }); // Server publishes project_updated via WS. } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to rename project'); } } async function handleArchiveSession(sessionId: string, projectId: string) { try { await api.sessions.archive(sessionId); // Server publishes session_archived via WS; useUserEvents delivers it. if (activeSession === sessionId) navigate(`/project/${projectId}`); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to archive session'); } } async function handleDeleteSession(sessionId: string, projectId: string) { try { await api.sessions.remove(sessionId); // Server publishes session_deleted via WS; useUserEvents delivers it. if (activeSession === sessionId) navigate(`/project/${projectId}`); } catch (err) { toast.error(err instanceof Error ? err.message : 'failed to delete session'); } } async function handleRenameSession(sessionId: string) { const trimmed = renameValue.trim(); setRenamingSession(null); if (!trimmed) return; try { await api.sessions.update(sessionId, { name: trimmed }); // Server publishes session_renamed via broker.publishUser; useUserEvents // forwards onto the bus. No local emit needed. } 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'; const { open: drawerOpen } = useSidebarDrawer(); const { isMobile } = useViewport(); const pull = usePullToRefresh(() => retry(), { enabled: isMobile }); // On mobile the sidebar is a slide-in drawer (fixed, z-40, off-screen by // default). On desktop it sits inline as a normal flex column. The // backdrop is rendered by AppShell; drawer-open state lives in // SidebarDrawerProvider. const asideCls = isMobile ? cn( 'fixed inset-y-0 left-0 z-40 w-60 border-r bg-sidebar text-sidebar-foreground flex flex-col', 'transition-transform duration-200 ease-out', drawerOpen ? 'translate-x-0' : '-translate-x-full', ) : 'w-60 shrink-0 border-r bg-sidebar text-sidebar-foreground flex flex-col h-screen'; return ( ); }