feat(webui): checkbox multi-select download in workspace tree
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user