POST /api/sessions/:sessionId/pending/create queues a pending_changes create via queueCreate (WriteGuardError -> 422 with the guard message). RightRail gains a 'New file from pasted text' modal (path + content) wired through api.coder.createPendingFile; sessionId is threaded down from App.tsx. The staged change shows in the CoderPane DiffPanel for explicit apply. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { ChevronRight, ChevronDown, FilePlus, FileText, Folder, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
|
import { api, ApiError } from '@/api/client';
|
|
import type { FileEntry } from '@/api/types';
|
|
import { inferLanguage } from '@/lib/attachments';
|
|
import { sessionEvents } from '@/hooks/sessionEvents';
|
|
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
|
import { useViewport } from '@/hooks/useViewport';
|
|
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface Props {
|
|
projectId: string;
|
|
sessionId: string;
|
|
}
|
|
|
|
const STORAGE_KEY = 'boocode.rightrail';
|
|
|
|
function basename(path: string): string {
|
|
if (!path) return '';
|
|
const parts = path.split('/');
|
|
return parts[parts.length - 1] ?? path;
|
|
}
|
|
|
|
function joinPath(parent: string, name: string): string {
|
|
if (!parent || parent === '.' || parent === '') return name;
|
|
return `${parent}/${name}`;
|
|
}
|
|
|
|
export function RightRail({ projectId, sessionId }: Props) {
|
|
const { isMobile } = useViewport();
|
|
const { open: drawerOpen, setOpen: setDrawerOpen } = useRightRailDrawer();
|
|
const [open, setOpen] = useState(() => {
|
|
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
|
|
});
|
|
const [filter, setFilter] = useState('');
|
|
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
|
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
|
|
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
|
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
|
|
|
|
// New-file-from-pasted-text modal. Queues a pending_changes create via
|
|
// BooCoder; it then shows in the CoderPane DiffPanel for explicit apply.
|
|
const [newFileOpen, setNewFileOpen] = useState(false);
|
|
const [newFilePath, setNewFilePath] = useState('');
|
|
const [newFileContent, setNewFileContent] = useState('');
|
|
const [creating, setCreating] = useState(false);
|
|
const [createError, setCreateError] = useState<string | null>(null);
|
|
|
|
const openNewFile = useCallback(() => {
|
|
setNewFilePath('');
|
|
setNewFileContent('');
|
|
setCreateError(null);
|
|
setNewFileOpen(true);
|
|
}, []);
|
|
|
|
const submitNewFile = useCallback(async () => {
|
|
const path = newFilePath.trim();
|
|
if (!path || creating) return;
|
|
setCreating(true);
|
|
setCreateError(null);
|
|
try {
|
|
await api.coder.createPendingFile(sessionId, path, newFileContent);
|
|
setNewFileOpen(false);
|
|
setNewFilePath('');
|
|
setNewFileContent('');
|
|
} catch (err) {
|
|
// 422 WriteGuardError surfaces via ApiError.message (the route's { error }).
|
|
setCreateError(err instanceof ApiError ? err.message : err instanceof Error ? err.message : 'Failed to create file');
|
|
} finally {
|
|
setCreating(false);
|
|
}
|
|
}, [sessionId, newFilePath, newFileContent, creating]);
|
|
|
|
// Combined open state: on mobile use the global drawer state (toggled by
|
|
// the Session header's FolderTree button); on desktop use the persistent
|
|
// internal state.
|
|
const isOpen = isMobile ? drawerOpen : open;
|
|
const closeRail = useCallback(() => {
|
|
if (isMobile) setDrawerOpen(false);
|
|
else setOpen(false);
|
|
}, [isMobile, setDrawerOpen]);
|
|
const openRail = useCallback(() => {
|
|
if (isMobile) setDrawerOpen(true);
|
|
else setOpen(true);
|
|
}, [isMobile, setDrawerOpen]);
|
|
|
|
useEffect(() => {
|
|
// best-effort; ignore failure because localStorage may be unavailable (quota, private mode)
|
|
try { localStorage.setItem(`${STORAGE_KEY}.open`, String(open)); } catch {}
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
api.projects.files(projectId).then((r) => {
|
|
if (!cancelled) setFullFileList(r.files);
|
|
}).catch(() => {});
|
|
return () => { cancelled = true; };
|
|
}, [projectId]);
|
|
|
|
const loadDir = useCallback(async (dirPath: string) => {
|
|
const apiPath = dirPath === '' ? '.' : dirPath;
|
|
try {
|
|
const result = await api.projects.listDir(projectId, apiPath);
|
|
setCache((prev) => { const next = new Map(prev); next.set(dirPath, result.entries); return next; });
|
|
} catch { /* ignore */ }
|
|
}, [projectId]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen) return;
|
|
if (!cache.has('')) void loadDir('');
|
|
}, [isOpen, cache, loadDir]);
|
|
|
|
function toggleDir(dirPath: string) {
|
|
setExpandedDirs((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(dirPath)) {
|
|
next.delete(dirPath);
|
|
} else {
|
|
next.add(dirPath);
|
|
if (!cache.has(dirPath)) void loadDir(dirPath);
|
|
}
|
|
return next;
|
|
});
|
|
}
|
|
|
|
async function openFile(path: string) {
|
|
try {
|
|
const result = await api.projects.viewFile(projectId, path);
|
|
setViewerFile({ path, content: result.content });
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
// Filter results
|
|
const trimmed = filter.trim().toLowerCase();
|
|
const filterActive = trimmed.length > 0;
|
|
|
|
interface FilterResult { path: string; name: string; }
|
|
|
|
const filterResults = useMemo<FilterResult[]>(() => {
|
|
if (!filterActive) return [];
|
|
if (fullFileList) {
|
|
const filenameMatches: string[] = [];
|
|
const pathOnly: string[] = [];
|
|
for (const p of fullFileList) {
|
|
const lp = p.toLowerCase();
|
|
if (!lp.includes(trimmed)) continue;
|
|
if (basename(p).toLowerCase().includes(trimmed)) filenameMatches.push(p);
|
|
else pathOnly.push(p);
|
|
}
|
|
filenameMatches.sort((a, b) => a.localeCompare(b));
|
|
pathOnly.sort((a, b) => a.localeCompare(b));
|
|
return [...filenameMatches, ...pathOnly].slice(0, 50).map((p) => ({ path: p, name: basename(p) }));
|
|
}
|
|
return [];
|
|
}, [filterActive, trimmed, fullFileList]);
|
|
|
|
// Listen for open_file_in_browser events
|
|
useEffect(() => {
|
|
return sessionEvents.subscribe((event) => {
|
|
if (event.type !== 'open_file_in_browser') return;
|
|
if (!isOpen) openRail();
|
|
void openFile(event.path);
|
|
});
|
|
}, [isOpen, openRail, projectId]);
|
|
|
|
// Desktop closed state: render the floating chevron handle. Mobile never
|
|
// shows the handle — the toggle lives in the Session header on mobile.
|
|
if (!isMobile && !open) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
className="shrink-0 border-l bg-sidebar p-2 hover:bg-muted"
|
|
aria-label="Open file browser"
|
|
>
|
|
<PanelRightOpen size={16} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
const rootEntries = cache.get('') ?? [];
|
|
|
|
// Mobile: render as fixed-position right-side drawer (always mounted so
|
|
// the transform transition can animate in/out). Desktop: inline aside.
|
|
const asideCls = isMobile
|
|
? cn(
|
|
'fixed inset-y-0 right-0 z-40 w-[85vw] max-w-sm border-l bg-sidebar flex flex-col overflow-hidden',
|
|
'transition-transform duration-200 ease-out',
|
|
drawerOpen ? 'translate-x-0' : 'translate-x-full',
|
|
)
|
|
: 'w-64 shrink-0 border-l bg-sidebar flex flex-col h-full overflow-hidden';
|
|
|
|
return (
|
|
<>
|
|
<aside className={asideCls}>
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
|
<span className="text-xs font-medium flex-1">Files</span>
|
|
<button
|
|
type="button"
|
|
onClick={openNewFile}
|
|
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="New file from pasted text"
|
|
title="New file"
|
|
>
|
|
<FilePlus size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={closeRail}
|
|
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
|
aria-label="Close file browser"
|
|
>
|
|
<PanelRightClose size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="px-2 py-1.5 shrink-0">
|
|
<Input
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
placeholder="Filter files..."
|
|
className="h-7 text-xs"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto px-1 py-1">
|
|
{filterActive ? (
|
|
filterResults.length > 0 ? (
|
|
<ul className="list-none space-y-0.5">
|
|
{filterResults.map((r) => (
|
|
<li key={r.path}>
|
|
<button
|
|
type="button"
|
|
className="w-full flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-muted/60 text-left"
|
|
onClick={() => void openFile(r.path)}
|
|
>
|
|
<FileText size={12} className="text-muted-foreground shrink-0" />
|
|
<span className="font-bold truncate">{r.name}</span>
|
|
<span className="text-muted-foreground ml-1 truncate">{r.path}</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
|
|
)
|
|
) : (
|
|
<TreeLevel
|
|
parentPath=""
|
|
entries={rootEntries}
|
|
cache={cache}
|
|
expanded={expandedDirs}
|
|
depth={0}
|
|
onToggleDir={toggleDir}
|
|
onSelectFile={(path) => void openFile(path)}
|
|
/>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
|
|
{viewerFile && (
|
|
<FileViewerOverlay
|
|
path={viewerFile.path}
|
|
content={viewerFile.content}
|
|
lang={inferLanguage(viewerFile.path)}
|
|
projectId={projectId}
|
|
onClose={() => setViewerFile(null)}
|
|
onNavigate={(path) => void openFile(path)}
|
|
/>
|
|
)}
|
|
|
|
<Dialog open={newFileOpen} onOpenChange={setNewFileOpen}>
|
|
<DialogContent className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>New file from pasted text</DialogTitle>
|
|
<DialogDescription>
|
|
Queues a new file as a pending change. Review and apply it from the Coder pane.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="new-file-path" className="text-xs">Path (relative to project root)</Label>
|
|
<Input
|
|
id="new-file-path"
|
|
value={newFilePath}
|
|
onChange={(e) => setNewFilePath(e.target.value)}
|
|
placeholder="src/example.ts"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="new-file-content" className="text-xs">Content</Label>
|
|
<Textarea
|
|
id="new-file-content"
|
|
value={newFileContent}
|
|
onChange={(e) => setNewFileContent(e.target.value)}
|
|
placeholder="Paste file contents here…"
|
|
autoFocus
|
|
className="min-h-[180px] font-mono text-xs"
|
|
/>
|
|
</div>
|
|
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="ghost" onClick={() => setNewFileOpen(false)} disabled={creating}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={() => void submitNewFile()} disabled={creating || !newFilePath.trim()}>
|
|
{creating ? 'Creating…' : 'Create'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
interface TreeLevelProps {
|
|
parentPath: string;
|
|
entries: FileEntry[];
|
|
cache: Map<string, FileEntry[]>;
|
|
expanded: Set<string>;
|
|
depth: number;
|
|
onToggleDir: (dirPath: string) => void;
|
|
onSelectFile: (path: string) => void;
|
|
}
|
|
|
|
function TreeLevel({ parentPath, entries, cache, expanded, depth, onToggleDir, onSelectFile }: TreeLevelProps) {
|
|
const sorted = useMemo(() => {
|
|
const copy = [...entries];
|
|
copy.sort((a, b) => {
|
|
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
return copy;
|
|
}, [entries]);
|
|
|
|
return (
|
|
<ul className="list-none">
|
|
{sorted.map((entry) => {
|
|
const fullPath = joinPath(parentPath, entry.name);
|
|
const isExpanded = entry.kind === 'dir' && expanded.has(fullPath);
|
|
return (
|
|
<li key={fullPath}>
|
|
<div
|
|
className="flex items-center gap-1 px-1 py-0.5 text-xs cursor-default rounded hover:bg-muted/60"
|
|
style={{ paddingLeft: 4 + depth * 12 }}
|
|
onClick={() => {
|
|
if (entry.kind === 'dir') onToggleDir(fullPath);
|
|
else onSelectFile(fullPath);
|
|
}}
|
|
>
|
|
{entry.kind === 'dir' ? (
|
|
isExpanded ? <ChevronDown size={10} className="shrink-0" /> : <ChevronRight size={10} className="shrink-0" />
|
|
) : (
|
|
<span className="w-[10px] shrink-0" />
|
|
)}
|
|
{entry.kind === 'dir' ? (
|
|
<Folder size={12} className="text-muted-foreground shrink-0" />
|
|
) : (
|
|
<FileText size={12} className="text-muted-foreground shrink-0" />
|
|
)}
|
|
<span className="truncate">{entry.name}</span>
|
|
</div>
|
|
{entry.kind === 'dir' && isExpanded && cache.has(fullPath) && (
|
|
<TreeLevel
|
|
parentPath={fullPath}
|
|
entries={cache.get(fullPath) ?? []}
|
|
cache={cache}
|
|
expanded={expanded}
|
|
depth={depth + 1}
|
|
onToggleDir={onToggleDir}
|
|
onSelectFile={onSelectFile}
|
|
/>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
);
|
|
}
|