import { useId, useMemo, useRef, useState } from "react"; 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 { 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"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Label } from "@/components/ui/label"; import { Link } from "@/lib/router"; function tree(files: WorkFile[]) { const root: FileTreeNode = { name: "", path: "", kind: "dir", children: [] }; for (const file of files) { let parent = root; const parts = file.path.split("/"); for (let i = 0; i < parts.length; i++) { const name = parts[i]!; let node = parent.children.find((child) => child.name === name); if (!node) { node = { name, path: parts.slice(0, i + 1).join("/"), kind: i < parts.length - 1 || file.kind === "directory" ? "dir" : "file", children: [] }; parent.children.push(node); } parent = node; } } function sort(nodes: FileTreeNode[]) { nodes.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name)); nodes.forEach((node) => sort(node.children)); } sort(root.children); return root.children; } export function WorkFolderBrowser({ owner, exampleFiles, readOnly = false, fillHeight = false, allowTrashActions = false }: { owner: WorkFolderOwner; exampleFiles?: WorkFile[]; readOnly?: boolean; fillHeight?: boolean; allowTrashActions?: boolean }) { const queryClient = useQueryClient(); const [trash, setTrash] = useState(false); const [checkedFiles, setCheckedFiles] = useState(new Set()); const canManageTrash = !readOnly || allowTrashActions; const [selectedPath, setSelectedPath] = useState(null); const [expanded, setExpanded] = useState(new Set()); const [directory, setDirectory] = useState(""); const [announcement, setAnnouncement] = useState(""); const fileInput = useRef(null); const directoryId = useId(); const key = ["work-folders", owner.companyId, owner.scope, owner.ownerId]; const filesQuery = useQuery({ queryKey: [...key, "files", trash], queryFn: () => workFoldersApi.list(owner, trash), 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 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" }) => { if (readOnly && !(allowTrashActions && (action.type === "deleteSelected" || action.type === "restore"))) throw new Error("This cached file action is unavailable."); if (action.type === "upload") for (const file of action.files) await workFoldersApi.upload(owner, file, directory ? `${directory}/${file.name}` : file.name, crypto.randomUUID()); else if (action.type === "mkdir") { await workFoldersApi.operation(owner, { action: "mkdir", path: directory }, crypto.randomUUID()); setExpanded((before) => new Set([...before, directory])); } 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); } } else if (action.type === "restore" || action.type === "purge") await workFoldersApi.operation(owner, { action: action.type, fileId: action.fileId }, crypto.randomUUID()); else for (const run of (syncQuery.data ?? []).filter((run) => run.active)) await workFoldersApi.refresh(owner, run.runId); }, onMutate: () => setAnnouncement(""), onSuccess: async (_data, action) => { setAnnouncement(action.type === "refresh" ? "Refresh requested for the next safe run boundary." : "Files saved."); }, onSettled: () => queryClient.invalidateQueries({ queryKey: key }) }); const statuses = syncQuery.data ?? []; const failures = statuses.filter((status) => status.state === "failed"); const saving = mutation.isPending || statuses.some((status) => status.state === "saving"); const lastSaved = statuses.map((status) => status.lastSavedAt) .filter((value): value is string => Boolean(value)).sort().at(-1); const lastOperation = filesQuery.data?.lastOperationAt; const saveLabel = mutation.isPending ? "Saving…" : mutation.isError ? "Save failed" : syncQuery.isError || filesQuery.isError ? "Save status unavailable" : !exampleFiles && (syncQuery.isPending || filesQuery.isPending) ? "Loading save status…" : saving ? "Saving…" : failures.length > 0 ? "Run save failed" : !lastSaved && statuses.some((status) => status.active) ? "Waiting for first save" : lastSaved || lastOperation || files.length > 0 ? "Saved" : "No saved files"; const disabled = mutation.isPending || Boolean(exampleFiles); return { setTrash(value === "trash"); setSelectedPath(null); setCheckedFiles(new Set()); }} className={cn("flex min-h-0 flex-col gap-3", fillHeight && "flex-1")}>
{!readOnly && <> { const chosen = Array.from(event.currentTarget.files ?? []); event.currentTarget.value = ""; if (chosen.length) mutation.mutate({ type: "upload", files: chosen }); }} />} Files Trash {!trash && canManageTrash && checkedPaths.length > 0 && } {!readOnly && } {saveLabel} {lastSaved && Last agent save {new Date(lastSaved).toLocaleTimeString()}} {lastOperation && (!lastSaved || lastOperation > lastSaved) && Files updated {new Date(lastOperation).toLocaleTimeString()}}
{!readOnly && !trash &&
setDirectory(event.target.value)} placeholder="Root folder" />
} {[filesQuery.error, syncQuery.error, mutation.error].filter(Boolean).map((error, index) =>

{(error as Error).message}

)} {failures.length > 0 &&

{failures.length === 1 ? "A sandbox run could not save its files." : `${failures.length} sandbox runs could not save their files.`} The files below are saved copies.

    {failures.map((failure) =>
  • {failure.error}{" "} {failure.agentId && View failed run}
  • )}
}

{announcement}

{trash ?

Deleted cached files are retained here. Restore them to return them to Files.

{files.length === 0 ?

Trash is empty.

: files.map((file) =>
{file.path}{canManageTrash && } {!readOnly && Permanently delete {file.path}? This deleted copy and its deleted children will no longer be recoverable. Cancel mutation.mutate({ type: "purge", fileId: file.id })}>Permanently delete }
)}
:
{ 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)} 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); setCheckedFiles((before) => { const next = new Set(before); const remove = paths.every((candidate) => before.has(candidate)); for (const candidate of paths) { if (remove) next.delete(candidate); else next.add(candidate); } return next; }); }} onSelectFile={setSelectedPath} loading={!exampleFiles && filesQuery.isLoading} empty={{ title: "No files yet", description: readOnly ? "No cached files have been saved for this scope." : "Upload files here, or create them during a sandbox run." }} ariaLabel={`${owner.scope} files`} />
{selected &&
{selected.path} {selected.kind === "file" && !exampleFiles && }
} {preview.isLoading ?

Loading preview…

: preview.error ?

{preview.error.message}

: preview.data ?
:

Select a file to preview it.

}
}
; }