Files
EvoScientist-WebUI/src/app/components/WorkspacePanel.tsx
T

613 lines
18 KiB
TypeScript

"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
ChevronRight,
ChevronDown,
Folder,
FileText,
RefreshCw,
Download,
Loader2,
Image as ImageIcon,
Database,
Code2,
File as FileIcon,
} from "lucide-react";
import { useQueryState } from "nuqs";
import { cn } from "@/lib/utils";
import { WorkspaceFileDialog } from "@/app/components/WorkspaceFileDialog";
import type { WorkspaceEntry } from "@/app/api/workspace/route";
import {
WORKSPACE_CHANGED_EVENT,
type WorkspaceChangedDetail,
} from "@/lib/workspaceEvents";
import {
check as checkSelection,
emptySelection,
isChecked as isSelectionChecked,
isIndeterminate as isSelectionIndeterminate,
uncheck as uncheckSelection,
toParams as selectionToParams,
type WorkspaceSelection,
} from "@/lib/workspaceSelection";
async function listDir(
threadId: string,
path: string
): Promise<WorkspaceEntry[]> {
const res = await fetch(
`/api/workspace?${new URLSearchParams({ threadId, path })}`
);
const body = await res.json().catch(() => null);
if (!res.ok) throw new Error(body?.error || "Failed to list workspace.");
return (body?.entries ?? []) as WorkspaceEntry[];
}
async function listAll(threadId: string): Promise<{
entries: WorkspaceEntry[];
truncated: boolean;
}> {
const res = await fetch(
`/api/workspace?${new URLSearchParams({ threadId, recursive: "1" })}`
);
const body = await res.json().catch(() => null);
if (!res.ok) throw new Error(body?.error || "Failed to load workspace.");
return {
entries: (body?.entries ?? []) as WorkspaceEntry[],
truncated: !!body?.truncated,
};
}
// Research-artifact categories for the "by type" view. Order here is render order.
const CATEGORIES = [
{
key: "docs",
label: "Papers & docs",
Icon: FileText,
exts: [
"pdf",
"tex",
"bib",
"md",
"markdown",
"txt",
"docx",
"doc",
"docm",
"dotx",
"dotm",
"rtf",
"odt",
],
},
{
key: "figures",
label: "Figures",
Icon: ImageIcon,
exts: [
"png",
"jpg",
"jpeg",
"gif",
"svg",
"webp",
"bmp",
"tiff",
"tif",
"eps",
],
},
{
key: "data",
label: "Data",
Icon: Database,
exts: [
"json",
"jsonl",
"csv",
"tsv",
"xlsx",
"xls",
"xlsm",
"xlsb",
"xltx",
"xltm",
"ods",
"parquet",
"pkl",
"npy",
"npz",
"h5",
"hdf5",
"db",
"sqlite",
"yaml",
"yml",
"xml",
],
},
{
key: "code",
label: "Code",
Icon: Code2,
exts: [
"py",
"ipynb",
"js",
"ts",
"tsx",
"jsx",
"sh",
"bash",
"r",
"jl",
"cpp",
"cc",
"c",
"h",
"hpp",
"java",
"go",
"rs",
"m",
"rb",
],
},
] as const;
const OTHER = { key: "other", label: "Other", Icon: FileIcon } as const;
const EXT_TO_CATEGORY: Record<string, string> = {};
for (const cat of CATEGORIES) {
for (const ext of cat.exts) EXT_TO_CATEGORY[ext] = cat.key;
}
type ViewMode = "tree" | "type";
export function WorkspacePanel() {
const [threadId] = useQueryState("threadId");
const [view, setView] = useState<ViewMode>("tree");
// --- Tree view state (listing cache keyed by dir path; "" = root) ---
const [children, setChildren] = useState<Record<string, WorkspaceEntry[]>>(
{}
);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState<Set<string>>(new Set());
const [rootLoading, setRootLoading] = useState(false);
// --- By-type view state (flat recursive listing) ---
const [allFiles, setAllFiles] = useState<WorkspaceEntry[] | null>(null);
const [typeLoading, setTypeLoading] = useState(false);
const [truncated, setTruncated] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<{
path: string;
size: number;
} | null>(null);
const navigateFile = useCallback(
(p: string, s?: number) => setSelected({ path: p, size: s ?? 0 }),
[]
);
const [selection, setSelection] = useState<WorkspaceSelection>(
emptySelection()
);
const toggleSelection = useCallback((path: string) => {
setSelection((prev) =>
isSelectionChecked(prev, path)
? uncheckSelection(prev, path)
: checkSelection(prev, path)
);
}, []);
const loadDir = useCallback(
async (path: string) => {
if (!threadId) {
setChildren({});
setError("Start a conversation to use its workspace.");
return [];
}
setLoading((prev) => new Set(prev).add(path));
try {
const entries = await listDir(threadId, path);
setChildren((prev) => ({ ...prev, [path]: entries }));
if (path === "") setError(null);
return entries;
} catch (err) {
if (path === "") {
setError(err instanceof Error ? err.message : "Failed to load.");
}
throw err;
} finally {
setLoading((prev) => {
const next = new Set(prev);
next.delete(path);
return next;
});
}
},
[threadId]
);
const loadAll = useCallback(async () => {
if (!threadId) {
setAllFiles([]);
setError("Start a conversation to use its workspace.");
return;
}
setTypeLoading(true);
try {
const { entries, truncated } = await listAll(threadId);
setAllFiles(entries);
setTruncated(truncated);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load.");
} finally {
setTypeLoading(false);
}
}, [threadId]);
// Initial tree load. loadDir surfaces root failures via `error`; catch the
// rejection here so it doesn't become an unhandled promise rejection.
useEffect(() => {
setChildren({});
setAllFiles(null);
setExpanded(new Set());
setSelected(null);
setSelection(emptySelection());
setRootLoading(true);
void loadDir("")
.catch(() => {})
.finally(() => setRootLoading(false));
}, [threadId, loadDir]);
// Load the flat listing the first time the by-type view is opened.
useEffect(() => {
if (view === "type" && allFiles === null && !typeLoading) void loadAll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [view]);
const refresh = useCallback(async () => {
setError(null);
if (view === "type") {
await loadAll();
return;
}
setRootLoading(true);
// Re-fetch the root plus every currently-expanded dir so an open tree stays
// open and in sync with what the agent has written since.
await Promise.allSettled(["", ...expanded].map((p) => loadDir(p)));
setRootLoading(false);
}, [view, expanded, loadDir, loadAll]);
// A chat or background agent can write files without going through this
// panel. Coalesce those notifications so a burst of tool calls results in a
// single authoritative directory read after the filesystem write settles.
const workspaceRefreshTimerRef = useRef<
ReturnType<typeof setTimeout> | undefined
>(undefined);
useEffect(() => {
const handleWorkspaceChanged = (event: Event) => {
const changedThreadId = (event as CustomEvent<WorkspaceChangedDetail>)
.detail?.threadId;
if (!threadId || changedThreadId !== threadId) return;
if (workspaceRefreshTimerRef.current) {
clearTimeout(workspaceRefreshTimerRef.current);
}
workspaceRefreshTimerRef.current = setTimeout(() => {
workspaceRefreshTimerRef.current = undefined;
void refresh();
}, 250);
};
window.addEventListener(WORKSPACE_CHANGED_EVENT, handleWorkspaceChanged);
return () => {
window.removeEventListener(
WORKSPACE_CHANGED_EVENT,
handleWorkspaceChanged
);
if (workspaceRefreshTimerRef.current) {
clearTimeout(workspaceRefreshTimerRef.current);
workspaceRefreshTimerRef.current = undefined;
}
};
}, [refresh, threadId]);
const toggleDir = useCallback(
(path: string) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
if (!children[path]) void loadDir(path).catch(() => {});
}
return next;
});
},
[children, loadDir]
);
// Group the flat listing into categories (newest first within each group).
const grouped = useMemo(() => {
const map: Record<string, WorkspaceEntry[]> = {};
for (const f of allFiles ?? []) {
const key = EXT_TO_CATEGORY[f.ext] ?? OTHER.key;
(map[key] ??= []).push(f);
}
for (const list of Object.values(map)) {
list.sort((a, b) => b.mtime - a.mtime);
}
return map;
}, [allFiles]);
const selectionCount = selection.checked.size;
// Native <a href> download so large archives stream straight to disk. The
// URL-length guard keeps a huge selection from hitting server/header limits
// — the fix is to select fewer, higher-level folders instead.
const selectionDownload = useMemo<{
href: string | null;
tooLarge: boolean;
}>(() => {
if (!threadId || selectionCount === 0)
return { href: null, tooLarge: false };
const { include, exclude } = selectionToParams(selection);
const params = new URLSearchParams({ threadId });
for (const p of include) params.append("include", p);
for (const p of exclude) params.append("exclude", p);
const href = `/api/workspace/download?${params}`;
return href.length <= 7000
? { href, tooLarge: false }
: { href: null, tooLarge: true };
}, [threadId, selection, selectionCount]);
const refreshing = view === "type" ? typeLoading : rootLoading;
const renderEntries = (path: string, depth: number): React.ReactNode => {
const entries = children[path];
if (!entries) return null;
if (entries.length === 0 && depth === 0) {
return (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
No files in the workspace yet
</p>
);
}
return entries.map((entry) => {
const isOpen = expanded.has(entry.path);
const isLoadingDir = loading.has(entry.path);
const entryChecked = isSelectionChecked(selection, entry.path);
const entryIndeterminate =
entry.type === "dir" && isSelectionIndeterminate(selection, entry.path);
return (
<div key={entry.path}>
<div
className="flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-sm text-foreground transition-colors hover:bg-muted"
style={{ paddingLeft: `${depth * 14 + 4}px` }}
>
<input
type="checkbox"
className="size-3.5 shrink-0 accent-[var(--brand)]"
checked={entryChecked}
ref={(el) => {
if (el) el.indeterminate = entryIndeterminate;
}}
onChange={() => toggleSelection(entry.path)}
aria-label={`Select ${entry.name}`}
/>
<button
type="button"
onClick={() =>
entry.type === "dir"
? toggleDir(entry.path)
: setSelected({ path: entry.path, size: entry.size })
}
className="flex min-w-0 flex-1 items-center gap-1.5 text-left"
title={entry.name}
>
{entry.type === "dir" ? (
<>
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground">
{isLoadingDir ? (
<Loader2 className="size-3 animate-spin" />
) : isOpen ? (
<ChevronDown className="size-3.5" />
) : (
<ChevronRight className="size-3.5" />
)}
</span>
<Folder className="size-4 shrink-0 text-[var(--brand)]" />
</>
) : (
<>
<span className="size-4 shrink-0" />
<FileText className="size-4 shrink-0 text-muted-foreground" />
</>
)}
<span className="truncate">{entry.name}</span>
</button>
</div>
{entry.type === "dir" &&
isOpen &&
renderEntries(entry.path, depth + 1)}
</div>
);
});
};
const renderByType = (): React.ReactNode => {
if (!allFiles) return null;
if (allFiles.length === 0) {
return (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
No files in the workspace yet
</p>
);
}
const groups = [...CATEGORIES, OTHER];
return (
<div className="space-y-3">
{truncated && (
<p className="px-1 text-[11px] text-muted-foreground">
Showing the first files only — the workspace has more than the
limit.
</p>
)}
{groups.map((cat) => {
const files = grouped[cat.key];
if (!files || files.length === 0) return null;
const Icon = cat.Icon;
return (
<div key={cat.key}>
<div className="mb-0.5 flex items-center gap-1.5 px-1 text-[10px] font-semibold uppercase tracking-wider text-tertiary">
<Icon className="size-3.5" />
{cat.label}
<span className="text-muted-foreground">({files.length})</span>
</div>
{files.map((f) => {
const dir = f.path.includes("/")
? f.path.slice(0, f.path.lastIndexOf("/"))
: "";
return (
<button
key={f.path}
type="button"
onClick={() => setSelected({ path: f.path, size: f.size })}
className="flex w-full items-center gap-1.5 rounded-md px-1 py-1 text-left text-sm text-foreground transition-colors hover:bg-muted"
title={f.path}
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate">{f.name}</span>
{dir && (
<span className="ml-auto shrink-0 truncate pl-2 text-[11px] text-muted-foreground">
{dir}
</span>
)}
</button>
);
})}
</div>
);
})}
</div>
);
};
return (
<div className="flex min-h-0 flex-col">
<div className="flex items-center justify-between gap-2 pb-1.5">
{/* Tree / By-type toggle */}
<div className="flex items-center gap-0.5 rounded-md bg-muted p-0.5 text-xs">
{(["tree", "type"] as const).map((m) => (
<button
key={m}
type="button"
onClick={() => setView(m)}
className={cn(
"rounded px-2 py-0.5 font-medium transition-colors",
view === m
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
aria-pressed={view === m}
>
{m === "tree" ? "Tree" : "By type"}
</button>
))}
</div>
<div className="flex items-center gap-0.5">
<a
href={selectionDownload.href ?? "#"}
download
aria-disabled={!selectionDownload.href}
onClick={
selectionDownload.href ? undefined : (e) => e.preventDefault()
}
className={cn(
"inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs transition-colors",
selectionDownload.href
? "text-muted-foreground hover:bg-muted hover:text-foreground"
: "cursor-not-allowed text-muted-foreground/50"
)}
title={
selectionDownload.tooLarge
? "Selection is too large to download — select fewer, higher-level folders"
: selectionCount === 0
? "Select files or folders to download"
: "Download the selected items as a zip"
}
>
<Download className="size-3.5" />
Selected{selectionCount > 0 ? ` (${selectionCount})` : ""}
</a>
<a
href={
threadId
? `/api/workspace/download?${new URLSearchParams({ threadId })}`
: "#"
}
download
className="inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title="Download the whole workspace as a zip"
>
<Download className="size-3.5" />
All
</a>
<button
type="button"
onClick={refresh}
disabled={refreshing}
className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
aria-label="Refresh workspace"
title="Refresh"
>
<RefreshCw
className={cn("size-3.5", refreshing && "animate-spin")}
/>
</button>
</div>
</div>
{error ? (
<p className="px-2 py-6 text-center text-xs text-muted-foreground">
{error}
</p>
) : view === "tree" ? (
rootLoading && !children[""] ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
<div className="-mx-1">{renderEntries("", 0)}</div>
)
) : typeLoading && allFiles === null ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : (
renderByType()
)}
<WorkspaceFileDialog
path={selected?.path ?? null}
threadId={threadId}
size={selected?.size}
onClose={() => setSelected(null)}
onChanged={refresh}
onNavigate={navigateFile}
/>
</div>
);
}