Merge branch 'codex/work-folders-runtime-hardening-refresh' into codex/work-folders-staging-hardening-refresh
* codex/work-folders-runtime-hardening-refresh: Include cached directories in selection and recursive trash operations
This commit is contained in:
commit
a9a4260d5d
|
|
@ -90,6 +90,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(<FileTree
|
||||
nodes={[{ name: "empty", path: "empty", kind: "dir", children: [] }]}
|
||||
selectedFile={null} expandedDirs={new Set()} checkedFiles={new Set(["empty"])}
|
||||
onSelectFile={() => {}} 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": "",
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ function flattenVisibleNodes(
|
|||
return flattened;
|
||||
}
|
||||
|
||||
function checkboxState(node: FileTreeNode, checkedFiles: Set<string>) {
|
||||
function checkboxState(node: FileTreeNode, checkedFiles: Set<string>, includeDirectories: boolean) {
|
||||
if (node.kind === "file") {
|
||||
return {
|
||||
allChecked: checkedFiles.has(node.path),
|
||||
|
|
@ -154,7 +154,7 @@ function checkboxState(node: FileTreeNode, checkedFiles: Set<string>) {
|
|||
};
|
||||
}
|
||||
|
||||
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({
|
|||
<div aria-label={ariaLabel} role="tree">
|
||||
{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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -84,6 +85,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<HTMLElement>('[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"));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
@ -98,7 +110,7 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
|
|||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger value="trash">Trash</TabsTrigger>
|
||||
</TabsList>
|
||||
{!trash && canManageTrash && checkedPaths.length > 0 && <Button variant="outline" size="sm" disabled={disabled} onClick={() => mutation.mutate({ type: "deleteSelected", paths: checkedPaths })}><Trash2 aria-hidden />Move {checkedPaths.length} {checkedPaths.length === 1 ? "file" : "files"} to trash</Button>}
|
||||
{!trash && canManageTrash && deletePaths.length > 0 && <Button variant="outline" size="sm" disabled={disabled} onClick={() => mutation.mutate({ type: "deleteSelected", paths: deletePaths })}><Trash2 aria-hidden />Move {deletePaths.length} {selectionLabel}{deletePaths.length === 1 ? "" : "s"} to trash</Button>}
|
||||
{!readOnly && <Button variant="outline" size="sm" disabled={disabled || !statuses.some((status) => status.active)} onClick={() => mutation.mutate({ type: "refresh" })}><RefreshCw aria-hidden />Refresh sandbox</Button>}
|
||||
<span className="text-xs text-muted-foreground" role="status">{saveLabel}</span>
|
||||
{lastSaved && <span className="text-xs text-muted-foreground">Last agent save {new Date(lastSaved).toLocaleTimeString()}</span>}
|
||||
|
|
@ -129,13 +141,16 @@ export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillH
|
|||
</div>)}</TabsContent> : <TabsContent value="files" className={cn("grid min-h-0 gap-3 md:grid-cols-3", fillHeight && "flex-1 grid-rows-2 md:grid-rows-1")}>
|
||||
<div className={cn("min-h-0 overflow-auto rounded-md border", !fillHeight && "max-h-96")}><FileTree nodes={nodes} selectedFile={selectedPath} expandedDirs={expanded}
|
||||
onToggleDir={(filePath) => { 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;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<string>());
|
||||
const [status, setStatus] = useState("todo");
|
||||
const [priority, setPriority] = useState("medium");
|
||||
const [selectValue, setSelectValue] = useState("in_progress");
|
||||
|
|
@ -2306,6 +2308,20 @@ export function DesignGuide() {
|
|||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="File tree selection">
|
||||
<p className="text-sm text-muted-foreground">Cached file browsers include folders in selection, including empty folders. Other file trees select files by default.</p>
|
||||
<FileTree
|
||||
nodes={[{ name: "empty", path: "empty", kind: "dir", children: [] }, { name: "notes.txt", path: "notes.txt", kind: "file", children: [] }]}
|
||||
selectedFile={null} expandedDirs={new Set()} checkedFiles={checkedFilePaths}
|
||||
onSelectFile={() => {}} onToggleDir={() => {}} includeDirectoriesInSelection
|
||||
onToggleCheck={(path) => setCheckedFilePaths((before) => {
|
||||
const next = new Set(before);
|
||||
if (next.has(path)) next.delete(path); else next.add(path);
|
||||
return next;
|
||||
})}
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section title="Built-in Agent Lifecycle Chips">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A derived lifecycle chip (amber) for attention states. The lifecycle chip is separate from
|
||||
|
|
|
|||
Loading…
Reference in New Issue