diff --git a/ui/src/components/FileTree.test.tsx b/ui/src/components/FileTree.test.tsx index 2c07ec53cf..887848c43c 100644 --- a/ui/src/components/FileTree.test.tsx +++ b/ui/src/components/FileTree.test.tsx @@ -88,6 +88,18 @@ describe("FileTree", () => { expect(row("docs")?.getAttribute("aria-checked")).toBe("mixed"); }); + it.each([false, true])("includes empty directories in selection only when enabled (%s)", (includeDirectoriesInSelection) => { + act(() => { + root.render( {}} onToggleDir={() => {}} + includeDirectoriesInSelection={includeDirectoriesInSelection} + />); + }); + expect((row("empty")?.querySelector("input") as HTMLInputElement).checked).toBe(includeDirectoriesInSelection); + }); + it("renders file badges and host-only file extras", () => { const nodes = buildFileTree({ "wiki/very-long-page-slug.md": "", diff --git a/ui/src/components/FileTree.tsx b/ui/src/components/FileTree.tsx index 4e7a00f768..8e036d4043 100644 --- a/ui/src/components/FileTree.tsx +++ b/ui/src/components/FileTree.tsx @@ -146,7 +146,7 @@ function flattenVisibleNodes( return flattened; } -function checkboxState(node: FileTreeNode, checkedFiles: Set) { +function checkboxState(node: FileTreeNode, checkedFiles: Set, includeDirectories: boolean) { if (node.kind === "file") { return { allChecked: checkedFiles.has(node.path), @@ -154,7 +154,7 @@ function checkboxState(node: FileTreeNode, checkedFiles: Set) { }; } - const childFiles = collectAllPaths(node.children, "file"); + const childFiles = includeDirectories ? collectAllPaths([node]) : collectAllPaths(node.children, "file"); const childFilePaths = [...childFiles]; const allChecked = childFilePaths.length > 0 && childFilePaths.every((p) => checkedFiles.has(p)); const someChecked = childFilePaths.some((p) => checkedFiles.has(p)); @@ -250,6 +250,8 @@ export type FileTreeProps = { /** @deprecated Use fileTones for public surfaces. Kept for compatibility with host-only callers. */ fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined; showCheckboxes?: boolean; + /** Include directories themselves, including empty ones, in selection state. */ + includeDirectoriesInSelection?: boolean; /** Allow long file and directory names to wrap instead of forcing horizontal overflow. */ wrapLabels?: boolean; loading?: boolean; @@ -271,6 +273,7 @@ export function FileTree({ renderFileExtra, fileRowClassName, showCheckboxes = true, + includeDirectoriesInSelection = false, wrapLabels = true, loading = false, error, @@ -395,7 +398,7 @@ export function FileTree({
{visibleNodes.map(({ node, depth }, index) => { const expanded = node.kind === "dir" && expandedDirs.has(node.path); - const { allChecked, someChecked } = checkboxState(node, effectiveCheckedFiles); + const { allChecked, someChecked } = checkboxState(node, effectiveCheckedFiles, includeDirectoriesInSelection); const badge = fileBadges?.[node.path]; const tone = fileTones?.[node.path] ?? "default"; const extraClassName = node.kind === "file" ? fileRowClassName?.(node, allChecked) : undefined; diff --git a/ui/src/components/WorkFolderBrowser.selection.test.tsx b/ui/src/components/WorkFolderBrowser.selection.test.tsx index ab5355ebe2..f86f4f98c0 100644 --- a/ui/src/components/WorkFolderBrowser.selection.test.tsx +++ b/ui/src/components/WorkFolderBrowser.selection.test.tsx @@ -34,8 +34,9 @@ beforeEach(async () => { api.downloadUrl.mockReturnValue("/download"); api.operation.mockImplementation(async (_owner, operation) => { if (operation.action === "delete") { - deleted.push(...active.filter((file) => file.path === operation.path)); - active = active.filter((file) => file.path !== operation.path); + const matches = (file: typeof a) => file.path === operation.path || file.path.startsWith(`${operation.path}/`); + deleted.push(...active.filter(matches)); + active = active.filter((file) => !matches(file)); } else { active.push(...deleted.filter((file) => file.id === operation.fileId)); deleted = deleted.filter((file) => file.id !== operation.fileId); @@ -70,6 +71,39 @@ describe("cached file selection and retained trash", () => { await settle(); expect(container.textContent).toContain("restored file contents"); }); + it("moves an empty directory to trash when its checkbox is selected", async () => { + active = [{ id: "empty", path: "empty", kind: "directory" }]; + await act(async () => { await client.invalidateQueries(); }); + await settle(); + await click(checkbox("empty")); + expect(checkbox("empty").checked).toBe(true); + await click(button("Move 1 item to trash")!); + expect(active).toEqual([]); + expect(deleted.map((file) => file.path)).toEqual(["empty"]); + }); + it("deletes a selected directory once, including all its children", async () => { + active = [{ id: "dir", path: "docs", kind: "directory" }, { ...a, path: "docs/a.txt" }]; + await act(async () => { await client.invalidateQueries(); }); + await settle(); + await click(checkbox("docs")); + await click(button("Move 1 item to trash")!); + expect(active).toEqual([]); + expect(api.operation).toHaveBeenCalledTimes(1); + expect(api.operation).toHaveBeenCalledWith(owner, { action: "delete", path: "docs" }, expect.any(String)); + }); + it("does not recursively delete a parent after a child is unchecked", async () => { + active = [{ id: "dir", path: "docs", kind: "directory" }, { ...a, path: "docs/a.txt" }, { ...b, path: "docs/b.txt" }]; + await act(async () => { await client.invalidateQueries(); }); + await settle(); + await click(container.querySelector('[data-file-tree-path="docs"]')!); + await click(checkbox("docs")); + await click(checkbox("docs/a.txt")); + expect(checkbox("docs").checked).toBe(false); + expect(checkbox("docs").indeterminate).toBe(true); + await click(button("Move 1 file to trash")!); + expect(active.map((file) => file.path)).toEqual(["docs", "docs/a.txt"]); + expect(api.operation).toHaveBeenCalledWith(owner, { action: "delete", path: "docs/b.txt" }, expect.any(String)); + }); it("only shows trash action for checked files and restores from the Trash tab", async () => { expect(button("to trash")).toBeUndefined(); await click(checkbox("a.txt")); diff --git a/ui/src/components/WorkFolderBrowser.tsx b/ui/src/components/WorkFolderBrowser.tsx index 5345d1d8f5..f30c04b2dc 100644 --- a/ui/src/components/WorkFolderBrowser.tsx +++ b/ui/src/components/WorkFolderBrowser.tsx @@ -3,7 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Download, FolderPlus, RefreshCw, RotateCcw, Trash2, Upload } from "lucide-react"; import type { WorkFile, WorkFolderOwner } from "@paperclipai/shared"; import { workFoldersApi } from "@/api/work-folders"; -import { FileTree, type FileTreeNode } from "@/components/FileTree"; +import { FileTree, collectAllPaths, type FileTreeNode } from "@/components/FileTree"; import { FileContentViewer } from "@/components/FileViewerSheet"; import { Button } from "@/components/ui/button"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; @@ -48,9 +48,21 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH enabled: !exampleFiles, refetchInterval: 15_000, retry: false }); const syncQuery = useQuery({ queryKey: [...key, "sync"], queryFn: () => workFoldersApi.sync(owner), enabled: !exampleFiles, refetchInterval: 5000, retry: false }); const files = exampleFiles ?? filesQuery.data?.files ?? []; - const checkedPaths = files.filter((file) => file.kind === "file" && checkedFiles.has(file.path)).map((file) => file.path); const selected = files.find((file) => file.path === selectedPath); const nodes = useMemo(() => tree(files), [files]); + const availablePaths = collectAllPaths(nodes); + const checkedPaths = [...checkedFiles].filter((path) => availablePaths.has(path)); + const checkedPathSet = new Set(checkedPaths); + const deletePaths = checkedPaths.filter((path) => { + let parent = path; + while (parent.includes("/")) { + parent = parent.slice(0, parent.lastIndexOf("/")); + if (checkedPathSet.has(parent)) return false; + } + return true; + }); + const filePaths = new Set(files.filter((file) => file.kind === "file").map((file) => file.path)); + const selectionLabel = deletePaths.every((path) => filePaths.has(path)) ? "file" : "item"; const preview = useQuery({ queryKey: [...key, "preview", selected?.path, selected?.sha256], queryFn: () => workFoldersApi.preview(owner, selected!), enabled: !exampleFiles && !trash && selected?.kind === "file", retry: false }); const mutation = useMutation({ mutationFn: async (action: { type: "upload"; files: File[] } | { type: "mkdir" } | { type: "deleteSelected"; paths: string[] } | { type: "restore" | "purge"; fileId: string } | { type: "refresh" }) => { @@ -60,8 +72,8 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH else if (action.type === "deleteSelected") { for (const path of action.paths) { await workFoldersApi.operation(owner, { action: "delete", path }, crypto.randomUUID()); - setCheckedFiles((before) => { const next = new Set(before); next.delete(path); return next; }); - setSelectedPath((before) => before === path ? null : before); + setCheckedFiles((before) => new Set([...before].filter((candidate) => candidate !== path && !candidate.startsWith(`${path}/`)))); + setSelectedPath((before) => before === path || before?.startsWith(`${path}/`) ? null : before); } } else if (action.type === "restore" || action.type === "purge") await workFoldersApi.operation(owner, { action: action.type, fileId: action.fileId }, crypto.randomUUID()); @@ -96,7 +108,7 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH Files Trash - {!trash && canManageTrash && checkedPaths.length > 0 && } + {!trash && canManageTrash && deletePaths.length > 0 && } {!readOnly && } {saveLabel} {lastSaved && Last agent save {new Date(lastSaved).toLocaleTimeString()}} @@ -127,13 +139,16 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
)} :
{ setSelectedPath(filePath); setExpanded((before) => { const next = new Set(before); if (next.has(filePath)) next.delete(filePath); else next.add(filePath); return next; }); }} - showCheckboxes={canManageTrash && !exampleFiles} checkedFiles={new Set(checkedPaths)} + showCheckboxes={canManageTrash && !exampleFiles} checkedFiles={new Set(checkedPaths)} includeDirectoriesInSelection onToggleCheck={(path, kind) => { if (disabled) return; - const paths = kind === "file" ? [path] : files.filter((file) => file.kind === "file" && file.path.startsWith(`${path}/`)).map((file) => file.path); + const paths = kind === "file" ? [path] : [...availablePaths].filter((candidate) => candidate === path || candidate.startsWith(`${path}/`)); setCheckedFiles((before) => { const next = new Set(before); const remove = paths.every((candidate) => before.has(candidate)); + // A partial child selection must never leave its parent selected + // for a recursive delete. + for (const ancestor of before) if (path.startsWith(`${ancestor}/`)) next.delete(ancestor); for (const candidate of paths) { if (remove) next.delete(candidate); else next.add(candidate); } return next; }); diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index 201230bc03..2a0dd5fc0d 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -1,4 +1,5 @@ import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel"; +import { FileTree } from "@/components/FileTree"; import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect"; import { RepositoryEditor } from "@/components/RepositoryEditor"; import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker"; @@ -474,6 +475,7 @@ function TaskExecutionControlsExample() { } export function DesignGuide() { + const [checkedFilePaths, setCheckedFilePaths] = useState(new Set()); const [status, setStatus] = useState("todo"); const [priority, setPriority] = useState("medium"); const [selectValue, setSelectValue] = useState("in_progress"); @@ -2306,6 +2308,20 @@ export function DesignGuide() {
+
+

Cached file browsers include folders in selection, including empty folders. Other file trees select files by default.

+ {}} onToggleDir={() => {}} includeDirectoriesInSelection + onToggleCheck={(path) => setCheckedFilePaths((before) => { + const next = new Set(before); + if (next.has(path)) next.delete(path); else next.add(path); + return next; + })} + /> +
+

A derived lifecycle chip (amber) for attention states. The lifecycle chip is separate from