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