Skip to content

feat: file browser context menu, rename, delete-to-trash, and drag-to-move - #355

Open
lyhue1991 wants to merge 1 commit into
agegr:mainfrom
lyhue1991:feat/file-browser-context-menu-dnd
Open

feat: file browser context menu, rename, delete-to-trash, and drag-to-move#355
lyhue1991 wants to merge 1 commit into
agegr:mainfrom
lyhue1991:feat/file-browser-context-menu-dnd

Conversation

@lyhue1991

@lyhue1991 lyhue1991 commented Aug 2, 2026

Copy link
Copy Markdown

参考 JupyterLab filebrowser,在现有 FileExplorer 递归树上实现单文件/单文件夹的右键菜单、重命名、删除、拖拽移动、新建。多选留作后续。

改动

前端(components/

  • FileContextMenu(新):右键菜单,项随节点类型生成(重命名、移到回收站、新建文件/文件夹、下载、复制路径);视口内夹紧定位,外部点击 / ESC / 滚动 / 失焦关闭。
  • InlineFileNameInput(新):内联文件名编辑器,提交时选中「不含扩展名」部分(lastIndexOf('.')),Enter / blur 提交、ESC 取消,blur-after-Enter 用 settledRef 防双提交。
  • FileExplorer:右键菜单接线、内联重命名(span↔input 切换)、删除确认对话框、HTML5 拖拽移动(仅文件夹接受 drop,拒绝拖入自身/子孙,用 ref 绕开 dataTransfer 在 dragover 不可读的限制)、新建文件/文件夹(幽灵输入行 + 高亮新项)、rename/move 后 expandedPaths 重映射、file-op 错误/通知 toast。

后端(app/api/files/[...path]/route.ts

  • DELETE → 移到回收站(trash 包,平台回收站命令)。
  • PATCH {to} → 重命名/移动:basename 校验 + 源与目标父目录 realpath 安全校验 + isAncestorOrSelf 拒绝移入自身/子孙 + 409 不覆盖 + EXDEV 跨设备回退 cpSync+rmSync
  • POST ?type=create → 新建空文件(wx flag)/ 文件夹(mkdir),409 不覆盖。
  • 所有 handler 均先过 isApiRequestAllowed(request) 受信校验,写操作一律走 realpath + root allow-list(与现有 upload 一致,防软链逃逸)。

共享(lib/file-ops.ts,新)

  • FileOpErrorvalidateSingleFileNameisAncestorOrSelfauthorizeExistingPath
  • lib/file-ops.test.mjs:8 个单测(node:test + jiti),覆盖名字校验、祖先判断(含 name-prefix-not-ancestor 边界)、realpath 软链逃逸拒绝 / 404 / 指回 root 内的软链放行。

i18n

  • 新增 15 个 files.* 键(en.ts + zh-CN.ts)。

依赖

  • 新增 trash@^10.1.1(ESM,壳出平台回收站命令)。

验证

  • tsc --noEmit
  • npm run lint ✅(0 errors / 0 warnings)
  • node --test lib/file-ops.test.mjs ✅(8/8)
  • node --test lib/i18n/{format,registry}.test.mjs

范围外(后续)

多选、Ctrl-拖拽复制、移动覆盖确认、回收站依赖换平台原生命令实现。

手动测试清单

  • 右键重命名(Enter 提交 / ESC 取消 / 扩展名不选中)
  • 删除到回收站(系统回收站可还原)
  • 新建文件 / 文件夹
  • 拖文件入文件夹
  • 拖文件夹入另一文件夹
  • 拖入自身 / 子孙被拒
  • 目标已存在 → 409 提示

Reference JupyterLab's filebrowser. Single-file/folder operations on the
existing recursive tree (multi-select left for later).

Frontend (components/):
- FileContextMenu: right-click menu (rename, move to trash, new file/folder,
  download, copy path); clamps to viewport, closes on outside click/ESC/scroll
- InlineFileNameInput: inline editor; selects name-without-extension,
  commits on Enter/blur, cancels on Escape
- FileExplorer: context menu wiring, inline rename, delete-to-trash dialog,
  HTML5 drag-to-move (folder-only drop targets, rejects self/descendant),
  new file/folder via phantom input row, expandedPaths remap on rename/move,
  error/notice toasts

Backend (app/api/files/[...path]/route.ts):
- DELETE -> move to trash via 'trash' npm package
- PATCH {to} -> rename/move: basename validation, realpath root checks on
  source and target parent, isAncestorOrSelf guard, 409 on collision,
  EXDEV fallback to cpSync+rmSync
- POST ?type=create -> new empty file (wx flag) / folder (mkdir), 409 on collision
- All handlers gated by isApiRequestAllowed + realpath allow-list
  (blocks symlink escape, consistent with existing upload path)

Shared (lib/file-ops.ts):
- FileOpError, validateSingleFileName, isAncestorOrSelf, authorizeExistingPath
- lib/file-ops.test.mjs: 8 unit tests (node:test + jiti)

i18n: 15 new files.* keys in en.ts and zh-CN.ts
Dep: add trash@^10.1.1
Copilot AI review requested due to automatic review settings August 2, 2026 13:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR expands the existing recursive FileExplorer into a more full-featured file browser (inspired by JupyterLab), adding a right-click context menu and inline workflows for rename, create, delete-to-trash, and drag-to-move, backed by new server-side file operation endpoints with allowed-root + realpath security checks.

Changes:

  • Add UI primitives (FileContextMenu, InlineFileNameInput) and wire them into FileExplorer for context actions, inline editing, delete confirmation, and HTML5 drag-to-move.
  • Add backend file operations to /api/files/[...path] for create (POST), move/rename (PATCH), and trash delete (DELETE), plus shared helpers in lib/file-ops.ts and tests.
  • Add i18n strings and the trash dependency to support “move to trash” cross-platform.

Reviewed changes

Copilot reviewed 9 out of 10 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
package.json Adds trash dependency for delete-to-trash.
package-lock.json Locks trash and transitive dependencies.
lib/i18n/messages/en.ts Adds files.* strings for new file operations UI.
lib/i18n/messages/zh-CN.ts Adds files.* strings for new file operations UI.
lib/file-ops.ts Introduces shared helpers/errors for path authorization and validations.
lib/file-ops.test.mjs Adds unit tests for file-op validation + symlink/realpath authorization.
components/InlineFileNameInput.tsx New inline rename/new-item input with Enter/blur commit + ESC cancel.
components/FileContextMenu.tsx New context menu component with viewport clamping + dismissal behaviors.
components/FileExplorer.tsx Adds context menu, rename/create/delete flows, drag-to-move, and toasts/notices.
app/api/files/[...path]/route.ts Adds DELETE (trash), PATCH (rename/move), POST type=create endpoints.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +315 to +322
try {
await trash(realPath);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : String(error) },
{ status: 500 },
);
}
Comment on lines +387 to +397
try {
fs.renameSync(realSource, realTarget);
} catch (error) {
// Cross-device rename (e.g. tmpfs -> disk). Fall back to copy + remove.
if ((error as NodeJS.ErrnoException).code === "EXDEV") {
fs.cpSync(realSource, realTarget, { recursive: true });
fs.rmSync(realSource, { recursive: true, force: true });
} else {
throw error;
}
}
Comment on lines +1238 to +1242
items.push({
key: "download",
label: t("files.download"),
onClick: () => window.open(`/api/files/${encodeFilePathForApi(node.fullPath)}?type=download`, "_blank"),
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants