import { useEffect, useMemo, useRef, useState } from 'react'; import { NavLink, useLocation, useNavigate } from 'react-router-dom'; import { ChevronRight, ExternalLink, Folder, MessageSquare, Plus, Settings as SettingsIcon, X, Code } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { sessionEvents } from '@/hooks/sessionEvents'; 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, ApiError } 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, WorktreeRiskReport } from '@/api/types'; import { giteaUrlFor } from '@/lib/projectUrls'; import { isCoderSessionName } from '@/lib/coder-session'; 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); // Work-at-risk dialog: shown when a delete is blocked (409) because the // session's worktree holds uncommitted/unpushed/unmerged work. const [riskState, setRiskState] = useState<{ sessionId: string; projectId: string; name: string; message: string; reports: WorktreeRiskReport[]; } | null>(null); const [riskBusy, setRiskBusy] = useState(false); 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, name: string, force = false, ) { try { await api.sessions.remove(sessionId, force); // Server publishes session_deleted via WS; useUserEvents delivers it. setRiskState(null); if (activeSession === sessionId) navigate(`/project/${projectId}`); } catch (err) { // 409 => the server's work-loss guard blocked the delete. Open the // work-at-risk dialog with the per-worktree reports instead of toasting. if ( err instanceof ApiError && err.status === 409 && err.body && typeof err.body === 'object' && 'reports' in err.body ) { const body = err.body as { error?: string; reports?: WorktreeRiskReport[] }; setRiskState({ sessionId, projectId, name, message: body.error ?? 'This session has work at risk.', reports: body.reports ?? [], }); return; } toast.error(err instanceof Error ? err.message : 'failed to delete session'); } } // Stash the worktree's uncommitted changes (recoverable), then re-attempt the // delete. If unpushed/unmerged commits remain, the retry 409s again and the // dialog re-renders with the narrowed risk. async function handleStashAndRetry() { if (!riskState || riskBusy) return; setRiskBusy(true); try { const { results } = await api.sessions.worktreeStash(riskState.sessionId); const failed = results.find((r) => r.error); if (failed) { toast.error(`stash failed: ${failed.error}`); return; } await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, false); } catch (err) { toast.error(err instanceof Error ? err.message : 'stash failed'); } finally { setRiskBusy(false); } } // Explicit, destructive override — deletes despite work at risk. async function handleForceDelete() { if (!riskState || riskBusy) return; setRiskBusy(true); try { await handleDeleteSession(riskState.sessionId, riskState.projectId, riskState.name, true); } finally { setRiskBusy(false); } } // Route the user to commit it themselves — never auto-commit. Opens the // session workspace where they can use a terminal or agent pane. function handleGoCommit() { if (!riskState) return; const sessionId = riskState.sessionId; setRiskState(null); navigate(`/session/${sessionId}`); toast.info('Open a terminal or agent in this session, commit and push your work, then delete again.'); } 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, setOpen: setDrawerOpen } = 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'; // Work-at-risk dialog framing. The server returns 409 in two distinct // situations: (1) work genuinely at risk (reports has ≥1 atRisk entry), or // (2) it couldn't verify (BooCoder down/errored → reports is empty). These // are different user stories — "your work is in danger" vs "the checker is // offline" — so the dialog must not show one generic message for both. const atRiskReports = riskState?.reports.filter((r) => r.atRisk) ?? []; const verifyFailed = riskState !== null && atRiskReports.length === 0; const anyDirty = atRiskReports.some((r) => r.dirty); // Commit-based risk (unpushed/unmerged) that stash can NOT clear. When this is // all that remains (e.g. after a stash cleared the dirty changes), the dialog // explains why it re-blocked and hides the Stash button so it doesn't look // like stash "didn't work". const anyCommits = atRiskReports.some((r) => r.unpushed !== 0 || r.unmerged > 0); return ( ); }