diff --git a/server/src/index.ts b/server/src/index.ts index 4ed51088..4b80c532 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -2257,7 +2257,7 @@ app.delete("/api/workspace/file", requireAuth, async (c) => { const path = c.req.query("path") ?? "" if (!path) return c.json({ error: "path required" }, 400) const r = await vaultDelete(vault as VaultId, path, userId) - if (!r.ok) return c.json({ error: r.error }, 500) + if (!r.ok) return c.json({ error: r.error }, 400) return c.json({ ok: true }) }) diff --git a/server/src/workspace.ts b/server/src/workspace.ts index 921797fd..7ed2771a 100644 --- a/server/src/workspace.ts +++ b/server/src/workspace.ts @@ -3,7 +3,7 @@ * personal / repos). Auto-commits on write per user's design: * "每次修改自动 commit, log 记录动作"。 */ -import { readdir, readFile, writeFile, stat, lstat, mkdir, rm, unlink, symlink } from "node:fs/promises" +import { readdir, readFile, writeFile, stat, lstat, mkdir, rm, rmdir, unlink, symlink } from "node:fs/promises" // Re-using readFile for parsing focus/inbox markdown. import { existsSync } from "node:fs" import { execFile } from "node:child_process" @@ -234,11 +234,25 @@ export async function vaultDelete(vault: VaultId, relPath: string, user: string) try { const s = await stat(abs) if (s.isDirectory()) { - await rm(abs, { recursive: true, force: true }) + // Encrypted vault subtrees use rmdir (non-recursive) so an `rm -rf` of + // a whole credential bundle can't happen through this API. Empty + // leftover dirs still clean up; non-empty raises ENOTEMPTY → we surface + // it as a clean "directory not empty" error. + const isEncryptedDir = vault === "personal" && ( + relPath === ".loopat/vaults" || relPath.startsWith(".loopat/vaults/") + ) + if (isEncryptedDir) { + await rmdir(abs) + } else { + await rm(abs, { recursive: true, force: true }) + } } else { await unlink(abs) } } catch (e: any) { + if (e?.code === "ENOTEMPTY") { + return { ok: false, error: "directory not empty" } + } return { ok: false, error: e?.message ?? "delete failed" } } return { ok: true } diff --git a/web/src/FileTree.tsx b/web/src/FileTree.tsx index 58f78eb8..7f41ada1 100644 --- a/web/src/FileTree.tsx +++ b/web/src/FileTree.tsx @@ -75,7 +75,7 @@ export function FileTree({ } }, [triggerUpload, loopId]) - const getContextActions = useCallback((node: TreeNodeData): TreeContextAction[] => { + const getContextActions = useCallback((node: TreeNodeData, _ctx: { children: TreeNodeData[] | null }): TreeContextAction[] => { if (node.type === "dir") { return [ { label: "Upload here", icon: , action: "upload" }, @@ -159,7 +159,7 @@ function SectionFolder({ onUpload: () => void onReload: () => void reloadKey: number - getContextActions: (node: TreeNodeData) => TreeContextAction[] + getContextActions: (node: TreeNodeData, ctx: { children: TreeNodeData[] | null }) => TreeContextAction[] onAction: (action: string, node: TreeNodeData) => void treeId: string }) { diff --git a/web/src/components/Tree.tsx b/web/src/components/Tree.tsx index d441fb6d..d76731a7 100644 --- a/web/src/components/Tree.tsx +++ b/web/src/components/Tree.tsx @@ -52,8 +52,14 @@ export type TreeProps = { picked: string | null /** Called to load children of a directory */ onLoadChildren: (path: string) => Promise - /** Returns context menu items for a node */ - getContextActions: (node: TreeNodeData) => TreeContextAction[] + /** + * Returns context menu items for a node. + * + * `ctx.children` is the node's currently-loaded children (only meaningful + * for dirs). `null` when the dir hasn't been expanded yet — callers that + * want to gate actions on emptiness must treat null as "unknown". + */ + getContextActions: (node: TreeNodeData, ctx: { children: TreeNodeData[] | null }) => TreeContextAction[] /** Called when a context action is triggered */ onAction: (action: string, node: TreeNodeData) => void /** Depth offset (default 0) */ @@ -120,7 +126,7 @@ function TreeNode({ onPick: (path: string) => void picked: string | null onLoadChildren: (path: string) => Promise - getContextActions: (node: TreeNodeData) => TreeContextAction[] + getContextActions: (node: TreeNodeData, ctx: { children: TreeNodeData[] | null }) => TreeContextAction[] onAction: (action: string, node: TreeNodeData) => void renderNode?: (node: TreeNodeData, depth: number, isOpen: boolean, toggleOpen: () => void) => ReactNode nodeClassName?: (node: TreeNodeData, depth: number, isOpen: boolean, isPicked: boolean) => string @@ -157,6 +163,10 @@ function TreeNode({ }, [open, entry.path, onLoadChildren, children]) const handleContextMenu = (e: React.MouseEvent) => { + // If this node has no actions (e.g. encrypted files in personal vault), + // don't suppress the native menu — and don't open an empty bubble. + const items = getContextActions(entry, { children }).filter((a) => !a.hidden) + if (items.length === 0) return e.preventDefault() e.stopPropagation() setMenuPos({ x: e.clientX, y: e.clientY }) @@ -195,7 +205,7 @@ function TreeNode({ !a.hidden)} + items={getContextActions(entry, { children }).filter((a) => !a.hidden)} onAction={(action) => onAction(action, entry)} onClose={() => setMenuPos(null)} /> @@ -255,7 +265,7 @@ function TreeNode({ !a.hidden)} + items={getContextActions(entry, { children }).filter((a) => !a.hidden)} onAction={(action) => onAction(action, entry)} onClose={() => setMenuPos(null)} /> @@ -279,7 +289,7 @@ function TreeNode({ !a.hidden)} + items={getContextActions(entry, { children: null }).filter((a) => !a.hidden)} onAction={(action) => onAction(action, entry)} onClose={() => setMenuPos(null)} /> @@ -311,7 +321,7 @@ function TreeNode({ !a.hidden)} + items={getContextActions(entry, { children: null }).filter((a) => !a.hidden)} onAction={(action) => onAction(action, entry)} onClose={() => setMenuPos(null)} /> diff --git a/web/src/pages/ContextPage.tsx b/web/src/pages/ContextPage.tsx index 73581a63..ba332289 100644 --- a/web/src/pages/ContextPage.tsx +++ b/web/src/pages/ContextPage.tsx @@ -370,7 +370,12 @@ function VaultPane({ vault, initialFile, initialEditing }: { vault: VaultId; ini setCreating({ type: action === "new-file" ? "file" : "folder", path: node.path }) setNewName("") } else if (action === "delete") { - if (!confirm(`Delete "${node.name}"?`)) return + const msg = isSecretFile(vault, node.path) + ? `Delete encrypted credential "${node.name}"? This cannot be undone.` + : isSecretsFolder(vault, node.path) + ? `Delete folder "${node.name}"? Only works if the folder is empty.` + : `Delete "${node.name}"?` + if (!confirm(msg)) return vaultDeleteFile(vault, node.path).then((r) => { if (r.ok) { setPickedPath((p) => p === node.path ? null : p) @@ -382,9 +387,22 @@ function VaultPane({ vault, initialFile, initialEditing }: { vault: VaultId; ini } }, [vault]) - const getContextActions = useCallback((node: TreeNodeData): TreeContextAction[] => { - if (isSecretsFolder(vault, node.path)) return [] + const getContextActions = useCallback((node: TreeNodeData, ctx: { children: TreeNodeData[] | null }): TreeContextAction[] => { if (node.type === "dir") { + if (isSecretsFolder(vault, node.path)) { + // Encrypted dir: Delete only appears when children are loaded and + // empty. This is a stale-state hint (children may have changed since + // load) — the server's rmdir backstop is the authoritative guard + // against deleting non-empty credential dirs. + const empty = ctx.children !== null && ctx.children.length === 0 + return [ + { label: "New file", icon: , action: "new-file" }, + { label: "New folder", icon: , action: "new-folder" }, + ...(empty + ? [{ label: "Delete", icon: , action: "delete", danger: true }] + : []), + ] + } return [ { label: "New file", icon: , action: "new-file" }, { label: "New folder", icon: , action: "new-folder" }, @@ -600,9 +618,7 @@ function SearchIcon() { */ function isSecretsFolder(vault: VaultId, path: string): boolean { if (vault !== "personal") return false - if (!path.startsWith(".loopat/vaults/")) return false - const rest = path.slice(".loopat/vaults/".length) - return rest.length > 0 + return path === ".loopat/vaults" || path.startsWith(".loopat/vaults/") } function isSecretFile(vault: VaultId, path: string): boolean { if (vault !== "personal") return false