Deleting a BooChat session CASCADE-wipes its session_worktrees row, which would silently orphan uncommitted/unpushed/unmerged work in the worktree. Add a pre-DELETE gate: the server reads session_worktrees from the shared DB first (no row = chat-only session = delete immediately, zero round-trip), and for worktree-backed sessions calls a new BooCoder endpoint that runs git on the host (only the host systemd service can see /tmp/booworktrees). checkWorktreeWorkAtRisk reports dirty/unpushed/unmerged via the audited hostExec+shellEscape path; default branch is detected from refs/remotes/origin/HEAD (not the worktree's own branch), never hardcoded. Any at-risk worktree returns 409 with per-worktree RiskReport[]; force=true bypasses the check entirely. Fail-closed: coder unreachable/errored also blocks (force still escapes). The sidebar renders a block dialog distinguishing work-at-risk (Commit/Stash/Force) from couldn't-verify (Cancel/Force only); stash uses -u and re-blocks on remaining commits with an explanatory message. Commit never auto-commits — it routes the user to the session. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
675 lines
28 KiB
TypeScript
675 lines
28 KiB
TypeScript
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<string> {
|
|
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<string>): 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<Set<string>>(() => readExpanded());
|
|
const [renamingSession, setRenamingSession] = useState<string | null>(null);
|
|
const [renameValue, setRenameValue] = useState('');
|
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: string; name: string } | null>(null);
|
|
const [renamingProject, setRenamingProject] = useState<string | null>(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<string | null>(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 (
|
|
<aside className={asideCls}>
|
|
<div className="px-4 py-3 border-b flex items-center justify-between">
|
|
<NavLink to="/" className="font-semibold tracking-tight text-base">
|
|
BooCode
|
|
</NavLink>
|
|
<div className="flex items-center gap-1">
|
|
<Button size="icon-sm" variant="ghost" onClick={() => setAddOpen(true)} aria-label="Add project">
|
|
<Plus />
|
|
</Button>
|
|
{isMobile && (
|
|
<Button
|
|
size="icon-sm"
|
|
variant="ghost"
|
|
onClick={() => setDrawerOpen(false)}
|
|
aria-label="Close sidebar"
|
|
>
|
|
<X />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{isMobile && (pull.pullDist > 0 || pull.refreshing) && (
|
|
<div
|
|
className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
|
|
style={{
|
|
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80),
|
|
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease' : undefined,
|
|
}}
|
|
aria-live="polite"
|
|
>
|
|
{pull.refreshing
|
|
? 'Refreshing…'
|
|
: pull.pullDist >= 80
|
|
? 'Release to refresh'
|
|
: 'Pull to refresh'}
|
|
</div>
|
|
)}
|
|
|
|
<nav
|
|
className="flex-1 overflow-y-auto py-2"
|
|
onTouchStart={isMobile ? pull.onTouchStart : undefined}
|
|
onTouchMove={isMobile ? pull.onTouchMove : undefined}
|
|
onTouchEnd={isMobile ? pull.onTouchEnd : undefined}
|
|
onTouchCancel={isMobile ? pull.onTouchEnd : undefined}
|
|
>
|
|
{loading && data == null && (
|
|
<div className="space-y-2 px-2">
|
|
{[0, 1, 2, 3].map((i) => (
|
|
<div key={i} className="bg-muted/40 animate-pulse rounded h-6" />
|
|
))}
|
|
</div>
|
|
)}
|
|
{data != null && projects.length === 0 && (
|
|
<div className="px-4 py-2 text-xs text-muted-foreground">
|
|
No projects yet. Click + to add one.
|
|
</div>
|
|
)}
|
|
{error != null && !data && (
|
|
<div className="px-4 py-2 space-y-2">
|
|
<div className="text-xs text-muted-foreground">{error}</div>
|
|
<Button size="sm" variant="outline" onClick={retry}>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{data != null &&
|
|
projects.map((p) => {
|
|
const isActiveProject = activeProject === p.id;
|
|
const isExpanded = isActiveProject || expanded.has(p.id);
|
|
const visible = p.recent_sessions.slice(0, MAX_VISIBLE_SESSIONS);
|
|
return (
|
|
<div key={p.id} className="px-2">
|
|
<ContextMenu>
|
|
<ContextMenuTrigger asChild>
|
|
<div
|
|
className={`group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm ${rowCls(isActiveProject)}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label={isExpanded ? 'Collapse' : 'Expand'}
|
|
aria-expanded={isExpanded}
|
|
disabled={isActiveProject}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (isActiveProject) return;
|
|
toggle(p.id);
|
|
}}
|
|
className={cn(
|
|
'flex items-center justify-center size-4 shrink-0 opacity-70 hover:opacity-100',
|
|
isActiveProject &&
|
|
'opacity-50 cursor-not-allowed hover:opacity-50'
|
|
)}
|
|
>
|
|
<ChevronRight
|
|
className={`size-3.5 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
|
/>
|
|
</button>
|
|
{renamingProject === p.id ? (
|
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
<Folder className="size-3.5 shrink-0 opacity-70" />
|
|
<input
|
|
autoFocus
|
|
value={renameProjectValue}
|
|
onChange={(e) => setRenameProjectValue(e.target.value)}
|
|
onBlur={() => void handleRenameProject(p.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void handleRenameProject(p.id);
|
|
if (e.key === 'Escape') setRenamingProject(null);
|
|
}}
|
|
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<NavLink to={`/project/${p.id}`} className="flex items-center gap-2 min-w-0 flex-1">
|
|
<Folder className="size-3.5 shrink-0 opacity-70" />
|
|
<span className="truncate" title={p.name}>{p.name}</span>
|
|
</NavLink>
|
|
)}
|
|
</div>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem onSelect={() => {
|
|
setRenamingProject(p.id);
|
|
setRenameProjectValue(p.name);
|
|
}}>
|
|
Rename
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onSelect={() => setArchiveProjectConfirm({ id: p.id, name: p.name })}>
|
|
Archive
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem onSelect={() => {
|
|
const url = giteaUrlFor({ path: p.path, gitea_remote: p.gitea_remote });
|
|
window.open(url, '_blank', 'noopener');
|
|
}}>
|
|
<ExternalLink size={12} /> Open in Gitea
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
|
|
{isExpanded && (
|
|
<div className="ml-5 mt-0.5 space-y-0.5">
|
|
{visible.map((s) => (
|
|
<ContextMenu key={s.id}>
|
|
<ContextMenuTrigger asChild>
|
|
{renamingSession === s.id ? (
|
|
<div className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}>
|
|
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
|
|
<input
|
|
autoFocus
|
|
value={renameValue}
|
|
onChange={(e) => setRenameValue(e.target.value)}
|
|
onBlur={() => void handleRenameSession(s.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') void handleRenameSession(s.id);
|
|
if (e.key === 'Escape') setRenamingSession(null);
|
|
}}
|
|
className="bg-transparent border-b border-border text-sm outline-none flex-1 min-w-0"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<NavLink
|
|
to={`/session/${s.id}`}
|
|
className={`flex items-center gap-2 rounded-md px-2 py-1 text-sm min-w-0 ${rowCls(activeSession === s.id)}`}
|
|
>
|
|
{isCoderSessionName(s.name) ? (
|
|
<Code className="size-3.5 shrink-0 opacity-70" />
|
|
) : (
|
|
<MessageSquare className="size-3.5 shrink-0 opacity-70" />
|
|
)}
|
|
<span className="truncate flex-1" title={s.name}>{s.name}</span>
|
|
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
|
|
{relTime(s.updated_at)}
|
|
</span>
|
|
</NavLink>
|
|
)}
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem onSelect={() => {
|
|
setRenamingSession(s.id);
|
|
setRenameValue(s.name);
|
|
}}>
|
|
Rename
|
|
</ContextMenuItem>
|
|
<ContextMenuItem onSelect={() => void handleArchiveSession(s.id, p.id)}>
|
|
Archive
|
|
</ContextMenuItem>
|
|
<ContextMenuSeparator />
|
|
<ContextMenuItem
|
|
variant="destructive"
|
|
onSelect={() => setDeleteConfirm({ id: s.id, name: s.name })}
|
|
>
|
|
Delete
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
))}
|
|
{p.total_sessions > MAX_VISIBLE_SESSIONS && (
|
|
<NavLink
|
|
to={`/project/${p.id}`}
|
|
className="block rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-sidebar-accent/60"
|
|
>
|
|
View all ({p.total_sessions})
|
|
</NavLink>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* v1.9: bottom-pinned Settings button. In a session, opens/focuses the
|
|
workspace settings pane via the sessionEvents bus (Session.tsx owns
|
|
the panesHook). Outside a session there's no workspace to mount the
|
|
pane in, so we navigate to /settings (themes page) instead. */}
|
|
<div className="border-t shrink-0 p-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (activeSession) {
|
|
sessionEvents.emit({ type: 'open_settings_pane' });
|
|
if (isMobile) setDrawerOpen(false);
|
|
} else {
|
|
navigate('/settings');
|
|
if (isMobile) setDrawerOpen(false);
|
|
}
|
|
}}
|
|
className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-sm hover:bg-sidebar-accent/60 text-sidebar-foreground"
|
|
aria-label="Settings"
|
|
>
|
|
<SettingsIcon className="size-3.5 shrink-0 opacity-70" />
|
|
<span className="flex-1 text-left">Settings</span>
|
|
</button>
|
|
</div>
|
|
|
|
<AddProjectModal open={addOpen} onOpenChange={setAddOpen} onAdded={() => {}} />
|
|
|
|
<Dialog open={archiveProjectConfirm !== null} onOpenChange={(open) => { if (!open) setArchiveProjectConfirm(null); }}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Archive project?</DialogTitle>
|
|
<DialogDescription>
|
|
Removes {archiveProjectConfirm ? `"${archiveProjectConfirm.name}"` : 'this project'} from the sidebar. Files on disk are untouched. You can restore it later from the Archived Projects view.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex gap-2 justify-end pt-2">
|
|
<Button variant="outline" onClick={() => setArchiveProjectConfirm(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
if (archiveProjectConfirm) void handleArchiveProject(archiveProjectConfirm.id);
|
|
setArchiveProjectConfirm(null);
|
|
}}
|
|
>
|
|
Archive
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={deleteConfirm !== null} onOpenChange={(open) => { if (!open) setDeleteConfirm(null); }}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Delete session?</DialogTitle>
|
|
<DialogDescription>
|
|
This will permanently delete {deleteConfirm ? `"${deleteConfirm.name}"` : 'this session'} and all its chats and messages. This cannot be undone.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="flex gap-2 justify-end pt-2">
|
|
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => {
|
|
if (deleteConfirm) {
|
|
const projectId = projects.find((p) =>
|
|
p.recent_sessions.some((s) => s.id === deleteConfirm.id)
|
|
)?.id;
|
|
if (projectId) void handleDeleteSession(deleteConfirm.id, projectId, deleteConfirm.name);
|
|
}
|
|
setDeleteConfirm(null);
|
|
}}
|
|
>
|
|
Delete
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={riskState !== null} onOpenChange={(open) => { if (!open && !riskBusy) setRiskState(null); }}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{verifyFailed ? 'Could not verify worktree safety' : 'This session has work at risk'}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{verifyFailed ? (
|
|
<>
|
|
{riskState?.message ?? 'The worktree safety check is unavailable.'} Your work may be
|
|
fine, but it couldn't be checked — only force-delete if you're sure.
|
|
</>
|
|
) : anyDirty && anyCommits ? (
|
|
<>
|
|
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
|
|
changes <em>and</em> commits that aren't pushed or merged. Stash clears the
|
|
changes (recoverable), but the commits will still block — push them or force-delete.
|
|
</>
|
|
) : anyDirty ? (
|
|
<>
|
|
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan uncommitted
|
|
changes in its worktree. Stash them (recoverable), commit them, or force-delete.
|
|
</>
|
|
) : (
|
|
<>
|
|
Deleting {riskState ? `"${riskState.name}"` : 'this session'} would orphan commits that
|
|
aren't pushed or merged. Stashing won't recover these — push them, or
|
|
force-delete.
|
|
</>
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
{!verifyFailed && (
|
|
<div className="flex flex-col gap-2 py-1 text-sm">
|
|
{atRiskReports.map((r) => (
|
|
<div key={r.worktreePath} className="rounded border border-border/60 px-3 py-2">
|
|
<div className="font-mono text-xs text-muted-foreground truncate" title={r.worktreePath}>
|
|
{r.branch || r.worktreePath}
|
|
</div>
|
|
<ul className="mt-1 list-disc pl-5 text-foreground/90">
|
|
{r.error && <li className="text-destructive">git error: {r.error}</li>}
|
|
{r.dirty && <li>uncommitted changes</li>}
|
|
{r.unpushed === -1 && <li>local-only branch (no upstream)</li>}
|
|
{r.unpushed > 0 && <li>{r.unpushed} unpushed commit{r.unpushed === 1 ? '' : 's'}</li>}
|
|
{r.unmerged > 0 && <li>{r.unmerged} unmerged commit{r.unmerged === 1 ? '' : 's'}</li>}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-wrap gap-2 justify-end pt-2">
|
|
<Button variant="outline" disabled={riskBusy} onClick={() => setRiskState(null)}>
|
|
Cancel
|
|
</Button>
|
|
{!verifyFailed && (
|
|
<Button variant="outline" disabled={riskBusy} onClick={handleGoCommit}>
|
|
Commit…
|
|
</Button>
|
|
)}
|
|
{!verifyFailed && anyDirty && (
|
|
<Button variant="outline" disabled={riskBusy} onClick={() => void handleStashAndRetry()}>
|
|
Stash & delete
|
|
</Button>
|
|
)}
|
|
<Button variant="destructive" disabled={riskBusy} onClick={() => void handleForceDelete()}>
|
|
Force delete
|
|
</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</aside>
|
|
);
|
|
}
|