diff --git a/src/app/components/WorkspacePanel.tsx b/src/app/components/WorkspacePanel.tsx index afc782e..464a6d5 100644 --- a/src/app/components/WorkspacePanel.tsx +++ b/src/app/components/WorkspacePanel.tsx @@ -28,6 +28,15 @@ 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, @@ -188,6 +197,17 @@ export function WorkspacePanel() { [] ); + const [selection, setSelection] = useState( + 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) { @@ -243,6 +263,7 @@ export function WorkspacePanel() { setAllFiles(null); setExpanded(new Set()); setSelected(null); + setSelection(emptySelection()); setRootLoading(true); void loadDir("") .catch(() => {}) @@ -331,6 +352,26 @@ export function WorkspacePanel() { return map; }, [allFiles]); + const selectionCount = selection.checked.size; + // Native 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 => { @@ -346,40 +387,57 @@ export function WorkspacePanel() { 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 (
- + { + if (el) el.indeterminate = entryIndeterminate; + }} + onChange={() => toggleSelection(entry.path)} + aria-label={`Select ${entry.name}`} + /> + +
{entry.type === "dir" && isOpen && renderEntries(entry.path, depth + 1)} @@ -469,6 +527,30 @@ export function WorkspacePanel() { ))}
+ 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" + } + > + + Selected{selectionCount > 0 ? ` (${selectionCount})` : ""} +