feat(webui): checkbox multi-select download in workspace tree

This commit is contained in:
m4
2026-08-10 12:14:46 +08:00
parent 1d813c18d6
commit 9984c6bdc4
+112 -30
View File
@@ -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<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) {
@@ -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 <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 => {
@@ -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 (
<div key={entry.path}>
<button
type="button"
onClick={() =>
entry.type === "dir"
? toggleDir(entry.path)
: setSelected({ path: entry.path, size: entry.size })
}
className="flex w-full items-center gap-1.5 rounded-md py-1 pr-2 text-left text-sm text-foreground transition-colors hover:bg-muted"
<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` }}
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>
<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)}
@@ -469,6 +527,30 @@ export function WorkspacePanel() {
))}
</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