From 65b703dd9966783a6f26ea1ac78e74546797720b Mon Sep 17 00:00:00 2001 From: shenlvkang-collab <258506731+shenlvkang-collab@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:44:22 +0800 Subject: [PATCH 1/3] feat(files): download and upload files from the path picker The path picker (mobile Path key) is the fast way to browse: one folder per request, any allowed root. It could not move files, so the Files panel was still needed for that, and a desktop browser could not reach the picker at all. - GET /api/filesystem/download: any regular file the picker can list, sent as an attachment. - POST /api/filesystem/upload: one multipart file into the folder on screen. Same-origin check (shared with paste-image), per-IP rate bucket, bounded folder probe, sanitized basename, blocked-name check, O_EXCL|O_NOFOLLOW creation with " (n)" suffixes (never overwrites, never follows a planted symlink), partial files unlinked. 1GB cap (CODEMAN_MAX_PICKER_UPLOAD_BYTES). - Both resolve through resolveFilesystemPickerPath(), so they reach exactly what browse can list, including the multi-user root scoping. - Picker `fileTransfer` option: a download link per file row and an Upload button (multi-select, XHR progress) that reloads the folder and selects the last upload. On for the Path key and the new quick-browser button in the Files panel header; Link Existing stays browse-only. Co-Authored-By: Claude Opus 5.5 --- .changeset/picker-file-transfer.md | 5 + CLAUDE.md | 2 +- docs/architecture-invariants.md | 4 +- docs/wiki/Working-With-Files.md | 14 +- src/config/buffer-limits.ts | 9 + src/types/common.ts | 11 ++ src/web/public/i18n.js | 12 ++ src/web/public/index.html | 1 + src/web/public/keyboard-accessory.js | 128 +++++++++++++- src/web/public/panels-ui.js | 14 ++ src/web/public/styles.css | 47 +++++ src/web/route-helpers.ts | 20 +++ src/web/routes/file-routes.ts | 222 +++++++++++++++++++++++- src/web/routes/session-routes.ts | 29 +--- src/web/schemas.ts | 6 + test/routes/filesystem-transfer.test.ts | 219 +++++++++++++++++++++++ 16 files changed, 707 insertions(+), 36 deletions(-) create mode 100644 .changeset/picker-file-transfer.md create mode 100644 test/routes/filesystem-transfer.test.ts diff --git a/.changeset/picker-file-transfer.md b/.changeset/picker-file-transfer.md new file mode 100644 index 000000000..3c535d9cc --- /dev/null +++ b/.changeset/picker-file-transfer.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Path picker downloads and uploads files: a download link on every file row and an Upload button for the folder on screen, opened from the mobile Path key or the new quick-browser button in the Files panel header, so desktop browsers get the same fast browser. diff --git a/CLAUDE.md b/CLAUDE.md index adb384009..7b6f9f363 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,7 +217,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). Both call `findFilePathLinks()`, which adds `~/`, relative and folder paths without overlapping a rooted match or a URL; a relative path resolves against `workingDir` (`resolveLinkedFilePath`), `~/` only server-side on the session's host (attachment route), and `terminalPathLine()` joins a path a program hard-wrapped short of the edge. ⚠️ A folder link opens the **folder grid**: `POST /api/sessions/:id/folders` guards + GRANTS the folder (`folder-listing.ts`, own registry, never the 200-capped attachment registry), files come by bare name via `…/folders/:folderId/files/:name/*` through `resolveServableAttachmentPath` plus the same extension/regular-file gate on the symlink's resolved target, so no browser request carries a path; `~/` is refused in a Docker case (the container's home is not this host's). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view//...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API) and with NO fetch directive (open network, owner decision 2026-10-13: never restrict it or add `allow-same-origin` without the owner), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. ⚠️ **XLSX previews parse in the BROWSER**, never on the server; the admission limits that keep a hostile workbook from freezing the worker live in `src/web/CLAUDE.md` (Spreadsheet preview). → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer) -**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) +**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key + the Files panel's quick browser): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file, and (not in Link Existing) `GET /api/filesystem/download` + `POST /api/filesystem/upload`, which must resolve through the same `resolveFilesystemPickerPath()`; upload creates with `O_EXCL|O_NOFOLLOW`, never overwrites. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker) **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1836d1bf5..3e480185a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -394,10 +394,12 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli ### Filesystem path picker -**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. +**Filesystem path picker** (Link Existing "Browse" button + the extended mobile keyboard's `📁 Path` key + the Files panel's quick-browser button): a lazy one-directory-at-a-time browser over `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` serving the tapped file. With `fileTransfer` (Path key and Files panel, never Link Existing) it adds `GET /api/filesystem/download` (any regular file, always `attachment`) and `POST /api/filesystem/upload` (multipart field `file`, into the folder named by `path`). It starts at the active session's working directory (falling back to the Codeman Cases root, then `/mnt/d`, then the first root), hides dot entries, and inserts the chosen path **without** Enter so the prompt is not submitted. The companion `⌫ All` key clears only the current unsent prompt buffer and must never emit the agent's `/clear` command. ⚠️ **This is a second file-serving surface, so it carries the same confinement burden as [Attachments](#attachments) and does not inherit it automatically.** Traversal is allowlisted to Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured via `CODEMAN_FILE_PICKER_ROOTS`; sensitive trees are blocked and symlink escapes are rejected after `realpath` resolution rather than before. Without the realpath step a symlink inside an allowed root would walk straight out of it. `preview` reuses the shared conversion cache and the **global** `document-conversion-limiter`, which is what stops N concurrent large-document previews from forking N multi-minute converter processes. Content types are pinned: images and PDF inline, DOCX/PPTX through the converters, and Markdown/TXT/JSON as inert `text/plain` (never `text/html`, which would be stored XSS on our own origin). Size caps are 2MB for text and 50MB for binary/document previews. +⚠️ **Download and upload resolve through the SAME `resolveFilesystemPickerPath()` as browse** (roots, realpath containment, hidden opt-in, `isBlockedPickerPath`), so they can never reach a path a listing could not show; never give them their own resolution. The upload is the picker's only WRITE: same-origin multipart check (`isSameOriginMultipartRequest`, shared with paste-image), a per-IP bucket, a bounded probe of the folder (#516), a sanitized basename (`sanitizePickerUploadName`: no separators, control characters or leading dots), the blocked-path check on the FINAL name, and `O_CREAT|O_EXCL|O_NOFOLLOW` creation with ` (n)` suffixes, so it never overwrites and never follows a planted symlink; a truncated or failed stream is unlinked. Tests: `test/routes/filesystem-transfer.test.ts`. + ⚠️ **Ownership scoping is also not inherited, and both endpoints must do it themselves.** Two separate holes shipped in the original version and are now regression-guarded in `test/routes/file-routes.test.ts`: 1. The optional `sessionId` param adds that session's `workingDir` as a "Current Folder" root. It is looked up directly off `ctx.sessions`/`ctx.store` rather than through `findSessionOrFail`, so the `canAccessOwned` check has to be written out by hand. Without it a multi-user caller pins **another user's** working directory as a browse root just by passing their session id. It reports 404 rather than 403 so the endpoint does not confirm that a session id exists. diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index fe7835013..a691b3fa1 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -148,10 +148,12 @@ their workspace is bind-mounted at the same path. ## The path picker -For choosing a path rather than typing one. It appears in two places: +For choosing a path rather than typing one. It appears in three places: - **Browse** in **Add Case → Link Existing**. - The **📁 Path** key on the mobile keyboard bar. +- The **📂** quick-browser button in the Files panel header, so a desktop browser gets the + same fast browser as a phone. It browses one directory at a time and can show hidden entries on request. The current folder is an editable field: type or paste a path and press Enter (or **Go**) to jump @@ -162,6 +164,16 @@ choice is remembered per device. The picker inserts the path into your prompt **without** pressing Enter, so nothing is submitted by accident. Its sibling **⌫ All** key clears the unsent prompt, and never sends the agent's `/clear` command. +Opened from the Path key or the Files panel, it also transfers files: every file row has a +**⬇** download link (any file type, sent as a download, never rendered), and **⬆ Upload** +sends one or more files from your device into the folder on screen. An upload never +overwrites: a name already taken is saved as `name (1).ext`, and the uploaded file is +selected afterwards, so **Insert Path** puts it on the prompt in one tap. Uploads are capped +at 1GB per file (`CODEMAN_MAX_PICKER_UPLOAD_BYTES`) and 60 per minute; downloads follow the +global download cap. Both reach exactly what the picker can list, under the same roots and +blocks, and a sensitive name (`credentials.json`, a key file) is refused for upload as it is +for download. Link Existing's picker stays browse-only. + This is a separate file-serving surface from the viewer, with its own rules: it allowlists your home directory, the cases directory, and anything in `CODEMAN_FILE_PICKER_ROOTS`, and blocks sensitive trees. In multi-user mode a non-admin gets only their own user space as a diff --git a/src/config/buffer-limits.ts b/src/config/buffer-limits.ts index d0b092d1a..17bcad0da 100644 --- a/src/config/buffer-limits.ts +++ b/src/config/buffer-limits.ts @@ -123,6 +123,15 @@ export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAG */ export const MAX_PASTE_VIDEO_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_VIDEO_BYTES || '') || 1024 * 1024 * 1024; // 1GB +/** + * Maximum size (bytes) of one file uploaded through the filesystem path picker + * (`POST /api/filesystem/upload`, the picker's Upload button). Like the video + * cap it streams to disk, so it bounds disk use, not memory. 0 is not accepted + * here: an upload always has a ceiling. Override: CODEMAN_MAX_PICKER_UPLOAD_BYTES (bytes) + */ +export const MAX_PICKER_UPLOAD_BYTES = + parseInt(process.env.CODEMAN_MAX_PICKER_UPLOAD_BYTES || '') || 1024 * 1024 * 1024; // 1GB + // ============================================================================ // File Download Limits // ============================================================================ diff --git a/src/types/common.ts b/src/types/common.ts index 6ebebbbd6..3d35818bd 100644 --- a/src/types/common.ts +++ b/src/types/common.ts @@ -10,6 +10,7 @@ * - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice` * - ProcessStats — memory/CPU/child-count snapshot for resource monitoring * - FilesystemBrowseData — bounded path-picker directory listing returned to the web UI + * - FilesystemUploadData — where a path-picker upload was written */ /** @@ -99,6 +100,16 @@ export interface FilesystemBrowseData { truncated: boolean; } +/** Response payload for `POST /api/filesystem/upload` (path picker Upload). */ +export interface FilesystemUploadData { + /** Path the file was written to, under the requested (unresolved) folder path */ + path: string; + /** Final file name, after sanitizing and any ` (n)` collision suffix */ + name: string; + /** Bytes written */ + size: number; +} + /** Response payload for `PUT /api/sessions/:id/file-content` (File Viewer edit mode). */ export interface FileWriteData { /** Workspace-relative path as submitted */ diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index e9cb509bd..6467b3ce4 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -55,6 +55,11 @@ '应用于新本地会话;可用强度取决于模型和 Codex 版本。自定义端点、Docker 和远程会话保留自己的设置。', 'Default Codex model may only contain letters, digits, ".", "_", "-" and "/"': 'Codex 默认模型只能包含字母、数字、"."、"_"、"-" 和 "/"', + 'Upload files to this folder': '上传文件到此文件夹', + '\u2B06 Upload': '\u2B06 上传', + 'Insert Path': '插入路径', + 'Quick browser: browse any allowed folder, upload and download files': '快速浏览:浏览允许的文件夹,上传和下载文件', + 'Select a session first': '请先选择一个会话', 'Skip to terminal': '跳转到终端', 'Go to main page': '返回主页', 'Session tabs': '会话标签页', @@ -1245,6 +1250,13 @@ [/^You're up to date \(v(.+)\)\.$/, (_m, version) => `已是最新版本(v${version})。`], [/^Update available: v(.+)$/, (_m, version) => `有可用更新:v${version}`], [/^Selected: (.+)$/, (_m, value) => `已选择:${value}`], + // Path picker transfer: file names and paths are user text, passed through. + [/^Download (.+)$/, (_m, name) => `下载 ${name}`], + [/^Uploading (\d+)\/(\d+): (.+)$/, (_m, n, total, rest) => `正在上传 ${n}/${total}:${rest}`], + [/^Uploading (.+)$/, (_m, rest) => `正在上传 ${rest}`], + [/^Upload failed: (.+)$/, (_m, error) => `上传失败:${error}`], + [/^Uploaded (\d+) files$/, (_m, n) => `已上传 ${n} 个文件`], + [/^Uploaded (.+)$/, (_m, path) => `已上传 ${path}`], [/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`], [/^Will create: (.+)$/, (_m, path) => `将创建:${path}`], // The spreadsheet preview: an HTTP status, and the notice bar's counts. diff --git a/src/web/public/index.html b/src/web/public/index.html index 1e8e3da5e..a3e7233c4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -580,6 +580,7 @@

Resume Conversation

Files
+ diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index 8423427e9..c0831da00 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -23,7 +23,9 @@ * use, on a second tap, on any other accessory key, on a session switch * (refreshForActiveSession) and when the keyboard is dismissed (hide). * - PathPicker (singleton object) — Lazy server-side file/folder browser shared - * by Link Existing and the extended mobile keyboard bar. + * by Link Existing, the extended mobile keyboard bar and the Files panel's + * quick browser. With `fileTransfer` it also downloads files and uploads into + * the folder on screen (`/api/filesystem/download` and `/upload`). * * - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element. * Saves and restores previously focused element on deactivate. Used by Ralph wizard @@ -76,11 +78,17 @@ const PathPicker = { _previewPreviousFocus: null, _showHidden: false, _sortMode: PATH_PICKER_DEFAULT_SORT, + _uploading: false, + _uploadXhr: null, /** * Open the lazy filesystem browser. * @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean, - * title?: string, onSelect: (path: string) => void}} options + * title?: string, confirmLabel?: string, fileTransfer?: boolean, + * onSelect: (path: string) => void}} options + * `fileTransfer` adds per-file Download links and an Upload button for the + * folder on screen. Ignored with `fetchListing` (a container listing is not + * this host's filesystem). */ open(options) { this.close(false); @@ -119,6 +127,8 @@ const PathPicker = {
Loading...
+ + @@ -126,7 +136,7 @@ const PathPicker = {
Selected - None + None
@@ -138,6 +148,18 @@ const PathPicker = { this.overlay = overlay; overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path'; + if (options.confirmLabel) overlay.querySelector('.path-picker-confirm').textContent = options.confirmLabel; + if (this._transferEnabled()) { + const uploadBtn = overlay.querySelector('.path-picker-upload'); + const uploadInput = overlay.querySelector('.path-picker-upload-input'); + uploadBtn.hidden = false; + uploadBtn.addEventListener('click', () => uploadInput.click()); + uploadInput.addEventListener('change', () => { + const files = Array.from(uploadInput.files || []); + uploadInput.value = ''; + if (files.length) this.uploadFiles(files); + }); + } overlay.querySelector('.path-picker-close').addEventListener('click', () => this.close(true)); overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true)); overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm()); @@ -408,6 +430,7 @@ const PathPicker = { icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4'; const name = document.createElement('span'); name.className = 'path-picker-item-name'; + name.setAttribute('data-i18n-skip', ''); name.textContent = entry.name; open.append(icon, name); const modified = this._formatModified(entry.mtimeMs); @@ -445,6 +468,17 @@ const PathPicker = { } row.appendChild(open); + if (entry.type === 'file' && this._transferEnabled()) { + const download = document.createElement('a'); + download.className = 'path-picker-item-download'; + download.href = this._transferUrl('download', entry.path); + download.setAttribute('download', entry.name); + download.textContent = '\u2B07'; + download.title = `Download ${entry.name}`; + download.setAttribute('aria-label', `Download ${entry.name}`); + row.appendChild(download); + } + if (entry.type === 'directory' || !this._options.directoriesOnly) { const choose = document.createElement('button'); choose.type = 'button'; @@ -457,6 +491,88 @@ const PathPicker = { } }, + _transferEnabled() { + return !!(this._options?.fileTransfer && !this._options.fetchListing); + }, + + /** Picker download/upload URL for `path` (a file to fetch, or the folder to write into). */ + _transferUrl(kind, path) { + const params = new URLSearchParams({ path }); + if (this._options?.sessionId) params.set('sessionId', this._options.sessionId); + // Hidden entries resolve only with the toggle on, exactly like browse. + if (this._showHidden) params.set('showHidden', 'true'); + return (window.CodemanBase?.url || ((p) => p))(`/api/filesystem/${kind}?${params.toString()}`); + }, + + /** + * Upload `files` one at a time into the folder on screen, then reload it and + * select the last file written, so Select (or Insert) is one tap away. Sequential + * on purpose: one progress line, and a failure names the file it stopped at + * instead of interleaving errors. + */ + async uploadFiles(files) { + if (!this.overlay || this._uploading || !this._currentPath) return; + const folder = this._currentPath; + const loadSequence = this._loadSequence; + const status = this.overlay.querySelector('.path-picker-status'); + const uploadBtn = this.overlay.querySelector('.path-picker-upload'); + this._uploading = true; + uploadBtn.disabled = true; + let lastPath = ''; + let done = 0; + try { + for (const file of files) { + const label = files.length > 1 ? `Uploading ${done + 1}/${files.length}: ${file.name}` : `Uploading ${file.name}`; + status.classList.remove('error'); + status.textContent = `${label}...`; + const data = await this._uploadOne(folder, file, (percent) => { + if (this.overlay) status.textContent = `${label} (${percent}%)`; + }); + lastPath = data.path; + done += 1; + } + } catch (error) { + if (this.overlay) { + status.textContent = `Upload failed: ${error.message || 'unknown error'}`; + status.classList.add('error'); + } + } finally { + this._uploading = false; + this._uploadXhr = null; + if (this.overlay) uploadBtn.disabled = false; + } + if (!done || !this.overlay) return; + app.showToast?.(done === 1 ? `Uploaded ${lastPath}` : `Uploaded ${done} files`, 'success'); + // Stay put if the user navigated away mid-upload; otherwise show the new file. + if (loadSequence === this._loadSequence && this._currentPath === folder) { + await this.load(folder); + if (this._entries.some((entry) => entry.path === lastPath)) this.select(lastPath); + } + }, + + /** POST one file; resolves with the server's `{path, name, size}`. XHR for upload progress. */ + _uploadOne(folder, file, onProgress) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + this._uploadXhr = xhr; + xhr.open('POST', this._transferUrl('upload', folder)); + xhr.responseType = 'json'; + xhr.upload.onprogress = (event) => { + if (event.lengthComputable && event.total > 0) onProgress(Math.floor((event.loaded / event.total) * 100)); + }; + xhr.onload = () => { + const body = xhr.response; + if (xhr.status >= 200 && xhr.status < 300 && body?.success) resolve(body.data); + else reject(new Error(body?.error || `HTTP ${xhr.status}`)); + }; + xhr.onerror = () => reject(new Error('Network error')); + xhr.onabort = () => reject(new Error('Upload cancelled')); + const form = new FormData(); + form.append('file', file, file.name); + xhr.send(form); + }); + }, + select(path) { if (!this.overlay) return; this._selectedPath = path; @@ -577,6 +693,8 @@ const PathPicker = { }, close(restoreFocus = true) { + this._uploadXhr?.abort(); + this._uploadXhr = null; if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler); this._keydownHandler = null; this._loadSequence += 1; @@ -1161,10 +1279,12 @@ const KeyboardAccessoryBar = { if (!app.activeSessionId) return; const session = app.sessions?.get(app.activeSessionId); PathPicker.open({ - title: 'Insert File or Folder Path', + title: 'Files', + confirmLabel: 'Insert Path', sessionId: app.activeSessionId, initialPath: session?.workingDir || '', directoriesOnly: false, + fileTransfer: true, onSelect: (path) => { app.insertTerminalText?.(path); setTimeout(() => app.terminal?.focus(), 100); diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 2d447dbc4..2eb203244 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -3405,6 +3405,20 @@ Object.assign(CodemanApp.prototype, { ); }, + /** + * Open the lazy path picker (the mobile keyboard's Path key) from the Files + * panel: one folder per request instead of a 5,000-node tree walk, any allowed + * root rather than only this workspace, plus Upload and Download. Its Insert + * puts the chosen path on the prompt without Enter, like the Path key. + */ + openQuickFileBrowser() { + if (!this.activeSessionId) { + this.showToast?.('Select a session first', 'info'); + return; + } + KeyboardAccessoryBar.pickPath(); + }, + async toggleFileBrowserHidden() { const state = this._ensureFileBrowserState(); const rawInput = typeof state.filter === 'string' ? state.filter : ''; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f8efa7d75..cbf664d58 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -15042,6 +15042,53 @@ body.touch-device.cjk-input-visible .main { color: var(--danger, #ef4444); } +/* Upload (fileTransfer pickers only): sits between the status line and Sort, + * which the status line's flex pushes to the right. */ +.path-picker-toolbar:has(.path-picker-upload:not([hidden])) .path-picker-status { + flex: 1; +} + +.path-picker-upload { + flex: 0 0 auto; + padding: 3px 9px; + color: var(--accent); + font-size: 0.7rem; + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; +} + +.path-picker-upload[hidden] { + display: none; +} + +.path-picker-upload:disabled { + opacity: 0.55; + cursor: progress; +} + +.path-picker-item-download { + display: inline-flex; + align-self: center; + align-items: center; + justify-content: center; + min-width: 32px; + min-height: 30px; + margin-right: 6px; + color: var(--text-dim); + font-size: 0.9rem; + text-decoration: none; + border: 1px solid var(--border); + border-radius: 7px; +} + +.path-picker-item-download:hover, +.path-picker-item-download:focus-visible { + color: var(--accent); + border-color: var(--accent); +} + .path-picker-list { flex: 1; min-height: 140px; diff --git a/src/web/route-helpers.ts b/src/web/route-helpers.ts index 75a92fcb2..2e441e3a9 100644 --- a/src/web/route-helpers.ts +++ b/src/web/route-helpers.ts @@ -236,6 +236,26 @@ export async function isWorkingDirAllowedForUsername( return isWorkingDirAllowed({ username, role: user?.role ?? 'user' }, workingDir); } +/** + * Same-origin check for multipart uploads (paste-image, the path picker's + * upload). Cookies are SameSite=lax and multipart/form-data is a "simple" CORS + * request (no preflight), so a cross-origin `
` + * submit would attach the session cookie unimpeded. Origin (else Referer) must + * match the request's Host; a non-browser client that sends neither must supply + * `X-Codeman-CSRF`, a header browsers cannot add cross-origin without a + * preflight, which the CORS config does not allow from other origins. + */ +export function isSameOriginMultipartRequest(req: FastifyRequest): boolean { + const reqHost = req.headers.host; + const source = req.headers.origin ?? req.headers.referer; + if (!source) return !!req.headers['x-codeman-csrf']; + try { + return new URL(source).host === reqHost; + } catch { + return false; + } +} + /** Whether the caller is an admin (or single-user mode, where the sole user is admin). */ export function isAdmin(req: FastifyRequest): boolean { return !isMultiUserMode() || getAuthUser(req).role === 'admin'; diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index 395e6af20..e8def141b 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -8,8 +8,9 @@ import { FastifyInstance, type FastifyReply } from 'fastify'; import { basename as pathBasename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'; -import { createReadStream, realpathSync } from 'node:fs'; +import { constants as fsConstants, createReadStream, realpathSync } from 'node:fs'; import type { Readable } from 'node:stream'; +import { finished } from 'node:stream/promises'; import fs from 'node:fs/promises'; import { createHash, randomBytes } from 'node:crypto'; import { homedir } from 'node:os'; @@ -19,6 +20,7 @@ import type { FilesystemBrowseEntry, FilesystemBrowseRoot, FilesystemPreviewKind, + FilesystemUploadData, FileWriteData, } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; @@ -56,6 +58,7 @@ import { canAccessOwned, findSessionOrFail, getAuthUser, + isSameOriginMultipartRequest, parseBody, validateSessionFilePath, validateSessionFilePathLexical, @@ -69,12 +72,17 @@ import { remoteReadFile, type RemoteProbe, } from '../../remote-files.js'; -import { downloadTooLargeMessage, exceedsDownloadLimit } from '../../config/buffer-limits.js'; +import { MAX_PICKER_UPLOAD_BYTES, downloadTooLargeMessage, exceedsDownloadLimit } from '../../config/buffer-limits.js'; import { parseByteRange } from '../http-range.js'; import { isSensitivePath } from '../sensitive-path.js'; import { SseEvent } from '../sse-events.js'; import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js'; -import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema, FileWriteSchema } from '../schemas.js'; +import { + FilesystemBrowseQuerySchema, + FilesystemPreviewQuerySchema, + FilesystemUploadQuerySchema, + FileWriteSchema, +} from '../schemas.js'; import { MAX_EDITABLE_BYTES, applyEol, @@ -999,6 +1007,61 @@ async function resolveFilesystemPickerPath( }; } +// ===== Path picker file transfer ===== +// Download and upload ride the picker's own confinement (resolveFilesystemPickerPath: +// allowlisted roots, realpath containment, hidden opt-in, blocked/sensitive trees), +// so they reach exactly what the picker can already list, and nothing more. + +/** Uploads per client IP per minute: a disk-fill bound, generous for a batch pick. */ +const PICKER_UPLOADS_PER_MINUTE = 60; +const pickerUploadBuckets = new Map(); + +function consumePickerUploadToken(key: string, now = Date.now()): boolean { + const bucket = pickerUploadBuckets.get(key); + if (!bucket || now - bucket.windowStart >= 60_000) { + if (pickerUploadBuckets.size > 1000) pickerUploadBuckets.clear(); + pickerUploadBuckets.set(key, { windowStart: now, count: 1 }); + return true; + } + if (bucket.count >= PICKER_UPLOADS_PER_MINUTE) return false; + bucket.count += 1; + return true; +} + +/** Longest file name an upload keeps, in UTF-16 code units (ext4 allows 255 bytes). */ +const PICKER_UPLOAD_NAME_MAX = 120; + +/** + * The name an uploaded file is written under. The client-supplied name is only a + * hint: anything that could leave the folder or hide the file is removed (path + * separators, control characters, leading dots and spaces), and the stem is cut + * so the name stays well inside a filesystem's length limit with its extension. + */ +export function sanitizePickerUploadName(raw: string | undefined): string { + let name = String(raw ?? '') + .split(/[\\/]/) + .pop()! + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x1f\x7f]/g, '') + .trim() + .replace(/^[.\s]+/, ''); + if (name.length > PICKER_UPLOAD_NAME_MAX) { + const ext = extname(name).slice(0, 20); + name = name.slice(0, PICKER_UPLOAD_NAME_MAX - ext.length) + ext; + } + return name || 'upload'; +} + +/** `name`, then `stem (1).ext`, `stem (2).ext`, ... so an upload never overwrites. */ +function pickerUploadCandidateName(name: string, attempt: number): string { + if (attempt === 0) return name; + const ext = extname(name); + const stem = ext && ext !== name ? name.slice(0, -ext.length) : name; + return `${stem} (${attempt})${ext && ext !== name ? ext : ''}`; +} + +const PICKER_UPLOAD_MAX_RENAMES = 100; + function appendDownloadFlag(url: string): string { return `${url}${url.includes('?') ? '&' : '?'}download=true`; } @@ -1505,6 +1568,159 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even ); }); + // Download any regular file the picker can list (the preview route serves only + // the previewable kinds). Served as an attachment, never rendered. + app.get('/api/filesystem/download', { compress: false }, async (req, reply): Promise => { + const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemPreviewQuerySchema, req.query); + const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath( + ctx, + req, + requestedPath, + sessionId, + wantsHiddenPickerEntries(showHidden) + ); + if (isBlockedPickerPath(resolvedPath, blockedTrees)) { + throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked'); + } + let stat; + try { + stat = await fs.stat(resolvedPath); + } catch { + throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `File not found: ${candidatePath}`); + } + if (!stat.isFile()) { + throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'Only files can be downloaded'); + } + const fileName = pathBasename(candidatePath); + reply.header('Cache-Control', 'no-cache'); + await serveRawFile( + reply, + { kind: 'local', resolvedPath, relativePath: '' }, + stat.size, + fileName, + extname(fileName).slice(1).toLowerCase(), + true, + req.headers.range + ); + }); + + // Upload one file into the folder the picker is showing. Multipart field `file`; + // the destination folder is the `path` query, confined like a browse. The file is + // created exclusively (never overwrites, never follows a planted symlink): a name + // already taken gets a ` (n)` suffix, and the answer carries the path written. + app.post('/api/filesystem/upload', async (req, reply): Promise> => { + if (!isSameOriginMultipartRequest(req)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed'); + } + if (!consumePickerUploadToken(req.ip)) { + reply.code(429); + reply.header('Retry-After', '60'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Rate limit exceeded (${PICKER_UPLOADS_PER_MINUTE} uploads/min)` + ); + } + const { path: requestedPath, sessionId, showHidden } = parseBody(FilesystemUploadQuerySchema, req.query); + const includeHidden = wantsHiddenPickerEntries(showHidden); + const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath( + ctx, + req, + requestedPath, + sessionId, + includeHidden + ); + if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked'); + } + // The folder can sit on a network mount that stopped answering (#516): probe + // it bounded before the first write, as paste-image does for a workspace. + const kind = await probePathKind(resolvedPath, { pastCap: true }); + if (kind === 'unknown') { + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + describeUnknownPath('Folder', candidatePath, { pastCap: true }) + ); + } + if (kind !== 'directory') { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The upload path must be a folder'); + } + if (!req.isMultipart()) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data'); + } + + let part: import('@fastify/multipart').MultipartFile | undefined; + try { + part = await req.file({ limits: { fileSize: MAX_PICKER_UPLOAD_BYTES }, throwFileSizeLimit: false }); + } catch (err: unknown) { + reply.code(413); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload'); + } + if (!part || part.fieldname !== 'file') { + if (part) await finished(part.file.resume()).catch(() => {}); + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected one file in the "file" field'); + } + + const baseName = sanitizePickerUploadName(part.filename); + let finalName = ''; + let handle: import('node:fs/promises').FileHandle | undefined; + for (let attempt = 0; attempt < PICKER_UPLOAD_MAX_RENAMES && !handle; attempt++) { + const name = pickerUploadCandidateName(baseName, attempt); + if (isBlockedPickerPath(join(resolvedPath, name), blockedTrees)) { + await finished(part.file.resume()).catch(() => {}); + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Uploading a file named "${name}" is blocked`); + } + try { + handle = await fs.open( + join(resolvedPath, name), + fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW, + 0o644 + ); + finalName = name; + } catch (err: unknown) { + if ((err as NodeJS.ErrnoException).code !== 'EEXIST') { + await finished(part.file.resume()).catch(() => {}); + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be written'); + } + } + } + if (!handle) { + await finished(part.file.resume()).catch(() => {}); + reply.code(409); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Too many files named like "${baseName}"`); + } + + const target = join(resolvedPath, finalName); + let size = 0; + try { + for await (const chunk of part.file) { + size += chunk.length; + // writeFile loops until the whole chunk is on disk; a bare write() may be short. + await handle.writeFile(chunk); + } + } catch (err: unknown) { + await handle.close().catch(() => {}); + await fs.unlink(target).catch(() => {}); + throw err; + } + await handle.close(); + if (part.file.truncated) { + await fs.unlink(target).catch(() => {}); + reply.code(413); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `File too large (max ${Math.round(MAX_PICKER_UPLOAD_BYTES / (1024 * 1024))}MB)` + ); + } + return { success: true, data: { path: join(candidatePath, finalName), name: finalName, size } }; + }); + // File tree listing app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 953cc5533..43d9c6328 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -101,6 +101,7 @@ import { getAuthUser, isAdmin, isPlainPromptInput, + isSameOriginMultipartRequest, isWorkingDirAllowed, ownerFor, parseBody, @@ -5360,32 +5361,8 @@ export function registerSessionRoutes( // The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts). app.post('/api/sessions/:id/paste-image', async (req, reply) => { - // CSRF defense: state-changing routes must come from same origin. - // Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request - // (no preflight), so a cross-origin - // submit attaches the session cookie unimpeded. Reject unless Origin/Referer - // matches req.host. Non-browser clients (no Origin AND no Referer) must - // supply X-Codeman-CSRF — a header browsers cannot add cross-origin without - // a preflight, which our CORS config does not allow from other origins. - const reqHost = req.headers.host; - const origin = req.headers.origin; - const referer = req.headers.referer; - let csrfOk = false; - if (origin) { - try { - csrfOk = new URL(origin).host === reqHost; - } catch { - /* invalid Origin → not ok */ - } - } else if (referer) { - try { - csrfOk = new URL(referer).host === reqHost; - } catch { - /* invalid Referer → not ok */ - } - } else { - csrfOk = !!req.headers['x-codeman-csrf']; - } + // CSRF defense: state-changing routes must come from same origin (see the helper). + const csrfOk = isSameOriginMultipartRequest(req); if (!csrfOk) { reply.code(403); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'CSRF check failed'); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index f40a2a163..15975525a 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -103,6 +103,12 @@ export const FilesystemPreviewQuerySchema = z.object({ showHidden: showHiddenQuerySchema, }); +/** + * Query validation for a path-picker upload: `path` names the destination + * FOLDER, resolved and confined exactly like a browse. + */ +export const FilesystemUploadQuerySchema = FilesystemPreviewQuerySchema; + /** * Body validation for `PUT /api/sessions/:id/file-content` (File Viewer edit * mode). `content.max()` counts UTF-16 code units, which for UTF-8 output is diff --git a/test/routes/filesystem-transfer.test.ts b/test/routes/filesystem-transfer.test.ts new file mode 100644 index 000000000..39ff3b461 --- /dev/null +++ b/test/routes/filesystem-transfer.test.ts @@ -0,0 +1,219 @@ +/** + * @fileoverview Path picker file transfer: `GET /api/filesystem/download` and + * `POST /api/filesystem/upload`. Both ride the picker's own confinement, so the + * tests pin that they reach what a browse can list and nothing more: the folder + * on screen, never outside the allowed roots, never a blocked/sensitive name, and + * an upload never overwrites or follows a planted symlink. + * + * Runs against a real temp directory (the session's workingDir is the "Current + * Folder" root); uses app.inject(), so no port is needed. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import fastifyMultipart from '@fastify/multipart'; +import { mkdir, mkdtemp, readFile, readdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createMockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerFileRoutes, sanitizePickerUploadName } from '../../src/web/routes/file-routes.js'; + +const BOUNDARY = 'codemanboundary'; + +function multipartBody(fieldName: string, fileName: string, content: string | Buffer): Buffer { + return Buffer.concat([ + Buffer.from( + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${fileName}"\r\n` + + 'Content-Type: application/octet-stream\r\n\r\n' + ), + Buffer.isBuffer(content) ? content : Buffer.from(content), + Buffer.from(`\r\n--${BOUNDARY}--\r\n`), + ]); +} + +describe('path picker file transfer', () => { + let app: FastifyInstance; + let ctx: ReturnType; + let workspace: string; + let outside: string; + + beforeEach(async () => { + workspace = await realpath(await mkdtemp(join(tmpdir(), 'codeman-picker-transfer-'))); + outside = await realpath(await mkdtemp(join(tmpdir(), 'codeman-picker-outside-'))); + app = Fastify({ logger: false }); + await app.register(fastifyCookie); + await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 4 } }); + ctx = createMockRouteContext(); + ctx._session.workingDir = workspace; + registerFileRoutes(app, ctx as never); + installRouteErrorHandler(app); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + await rm(workspace, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + }); + + const upload = (folder: string, fileName: string, content: string | Buffer, headers: Record = {}) => + app.inject({ + method: 'POST', + url: `/api/filesystem/upload?sessionId=${ctx._sessionId}&path=${encodeURIComponent(folder)}`, + headers: { 'x-codeman-csrf': '1', 'content-type': `multipart/form-data; boundary=${BOUNDARY}`, ...headers }, + payload: multipartBody('file', fileName, content), + }); + + describe('GET /api/filesystem/download', () => { + it('serves any regular file in an allowed root as an attachment', async () => { + await writeFile(join(workspace, 'build.tar.gz'), 'archive-bytes'); + const res = await app.inject({ + method: 'GET', + url: `/api/filesystem/download?sessionId=${ctx._sessionId}&path=${encodeURIComponent(join(workspace, 'build.tar.gz'))}`, + }); + expect(res.statusCode).toBe(200); + expect(res.body).toBe('archive-bytes'); + expect(res.headers['content-disposition']).toMatch(/^attachment; filename="build\.tar\.gz"/); + expect(res.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('refuses a path outside the allowed roots', async () => { + await writeFile(join(outside, 'secret.txt'), 'nope'); + const res = await app.inject({ + method: 'GET', + url: `/api/filesystem/download?sessionId=${ctx._sessionId}&path=${encodeURIComponent(join(outside, 'secret.txt'))}`, + }); + expect(res.statusCode).toBe(403); + }); + + it('refuses a symlink that escapes the roots', async () => { + await writeFile(join(outside, 'secret.txt'), 'nope'); + await symlink(join(outside, 'secret.txt'), join(workspace, 'link.txt')); + const res = await app.inject({ + method: 'GET', + url: `/api/filesystem/download?sessionId=${ctx._sessionId}&path=${encodeURIComponent(join(workspace, 'link.txt'))}`, + }); + expect(res.statusCode).toBe(403); + expect(res.body).not.toContain('nope'); + }); + + it('refuses a sensitive file', async () => { + await writeFile(join(workspace, '.env'), 'TOKEN=x'); + const res = await app.inject({ + method: 'GET', + url: `/api/filesystem/download?sessionId=${ctx._sessionId}&path=${encodeURIComponent(join(workspace, '.env'))}&showHidden=true`, + }); + expect(res.statusCode).toBe(403); + expect(res.body).not.toContain('TOKEN'); + }); + + it('refuses a folder', async () => { + await mkdir(join(workspace, 'sub')); + const res = await app.inject({ + method: 'GET', + url: `/api/filesystem/download?sessionId=${ctx._sessionId}&path=${encodeURIComponent(join(workspace, 'sub'))}`, + }); + expect(res.statusCode).toBe(400); + }); + }); + + describe('POST /api/filesystem/upload', () => { + it('writes the file into the requested folder and returns its path', async () => { + await mkdir(join(workspace, 'docs')); + const res = await upload(join(workspace, 'docs'), 'report.pdf', 'pdf-bytes'); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).toEqual({ + success: true, + data: { path: join(workspace, 'docs', 'report.pdf'), name: 'report.pdf', size: 9 }, + }); + expect(await readFile(join(workspace, 'docs', 'report.pdf'), 'utf8')).toBe('pdf-bytes'); + }); + + it('never overwrites: a taken name gets a numbered suffix', async () => { + await writeFile(join(workspace, 'notes.txt'), 'original'); + const first = JSON.parse((await upload(workspace, 'notes.txt', 'second')).body); + const second = JSON.parse((await upload(workspace, 'notes.txt', 'third')).body); + expect(first.data.name).toBe('notes (1).txt'); + expect(second.data.name).toBe('notes (2).txt'); + expect(await readFile(join(workspace, 'notes.txt'), 'utf8')).toBe('original'); + expect(await readFile(join(workspace, 'notes (1).txt'), 'utf8')).toBe('second'); + }); + + it('does not follow a symlink planted under the target name', async () => { + await writeFile(join(outside, 'victim.txt'), 'untouched'); + await symlink(join(outside, 'victim.txt'), join(workspace, 'victim.txt')); + const body = JSON.parse((await upload(workspace, 'victim.txt', 'payload')).body); + expect(body.data.name).toBe('victim (1).txt'); + expect(await readFile(join(outside, 'victim.txt'), 'utf8')).toBe('untouched'); + }); + + it('strips path components from the client file name', async () => { + const body = JSON.parse((await upload(workspace, '../../escape.sh', 'x')).body); + expect(body.data.path).toBe(join(workspace, 'escape.sh')); + expect(await readdir(outside)).toEqual([]); + }); + + it('refuses a folder outside the allowed roots', async () => { + const res = await upload(outside, 'a.txt', 'x'); + expect(res.statusCode).toBe(403); + expect(await readdir(outside)).toEqual([]); + }); + + it('refuses a sensitive file name', async () => { + const res = await upload(workspace, 'credentials.json', '{}'); + expect(res.statusCode).toBe(403); + expect(await readdir(workspace)).toEqual([]); + }); + + it('refuses a cross-site request', async () => { + const res = await upload(workspace, 'a.txt', 'x', { origin: 'https://evil.example', 'x-codeman-csrf': '' }); + expect(res.statusCode).toBe(403); + expect(await readdir(workspace)).toEqual([]); + }); + + it('refuses an unknown session (ownership boundary)', async () => { + const res = await app.inject({ + method: 'POST', + url: `/api/filesystem/upload?sessionId=missing&path=${encodeURIComponent(workspace)}`, + headers: { 'x-codeman-csrf': '1', 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }, + payload: multipartBody('file', 'a.txt', 'x'), + }); + expect(res.statusCode).toBe(404); + }); + + it('refuses the wrong multipart field', async () => { + const res = await app.inject({ + method: 'POST', + url: `/api/filesystem/upload?sessionId=${ctx._sessionId}&path=${encodeURIComponent(workspace)}`, + headers: { 'x-codeman-csrf': '1', 'content-type': `multipart/form-data; boundary=${BOUNDARY}` }, + payload: multipartBody('image', 'a.txt', 'x'), + }); + expect(res.statusCode).toBe(400); + expect(await readdir(workspace)).toEqual([]); + }); + }); + + describe('sanitizePickerUploadName', () => { + it.each([ + ['report.pdf', 'report.pdf'], + ['a/b/c.txt', 'c.txt'], + ['C:\\Users\\me\\photo.jpg', 'photo.jpg'], + ['..', 'upload'], + ['.bashrc', 'bashrc'], + [' spaced.txt ', 'spaced.txt'], + ['bad\u0000name\n.txt', 'badname.txt'], + ['', 'upload'], + [undefined, 'upload'], + ])('%j -> %j', (raw, expected) => { + expect(sanitizePickerUploadName(raw)).toBe(expected); + }); + + it('keeps the extension when cutting a long name', () => { + const name = sanitizePickerUploadName(`${'x'.repeat(300)}.docx`); + expect(name.length).toBeLessThanOrEqual(120); + expect(name.endsWith('.docx')).toBe(true); + }); + }); +}); From 0a38568e1c9411bbe8534d992e292b194a6b1529 Mon Sep 17 00:00:00 2001 From: shenlvkang-collab <258506731+shenlvkang-collab@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:14:34 +0800 Subject: [PATCH 2/3] fix(i18n): drop the duplicate 'Select a session first' entry The key already exists further down the table; an object literal keeps the last one, so the first copy was dead. Co-Authored-By: Claude Opus 5.5 --- src/web/public/i18n.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 6467b3ce4..9d9df44d8 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -59,7 +59,6 @@ '\u2B06 Upload': '\u2B06 上传', 'Insert Path': '插入路径', 'Quick browser: browse any allowed folder, upload and download files': '快速浏览:浏览允许的文件夹,上传和下载文件', - 'Select a session first': '请先选择一个会话', 'Skip to terminal': '跳转到终端', 'Go to main page': '返回主页', 'Session tabs': '会话标签页', From e52338d85328b95cb56e71c107536a24379b05f3 Mon Sep 17 00:00:00 2001 From: shenlvkang-collab <258506731+shenlvkang-collab@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:14:45 +0800 Subject: [PATCH 3/3] feat(files): load the Files panel one folder at a time, sort it, filter locally Opening the Files panel walked the whole workspace (depth 5, up to 5,000 nodes) on every open, refresh and session switch, and every keystroke in its search box re-walked it on the server. On WSL /mnt/d (9P, a stat per entry) that measured 13.6-14.1 s to open a 3,700-file workspace and 4.3 s per search, against 0.34-0.47 s for the path picker's one-folder listing of the same root. - `GET /api/sessions/:id/files?lazy=1[&path=]` lists one directory: folders without `children` (not loaded), `mtimeMs` on every entry, workspace-confined through validateSessionFilePath(), hidden segments need showHidden. The non-lazy response is unchanged. - The panel loads the root lazily and fetches a folder when it is expanded (loading row, error row that retries on collapse/expand, epoch-guarded). - A sort menu (name A-Z/Z-A, newest/oldest first, folders first), per device, same modes as the path picker. - Typing filters the already-loaded nodes in the browser with no request (ordered-substring `*` match, never a RegExp); Enter runs the existing workspace search. A folder hit the lazy tree has not loaded yet is opened by loading each ancestor in turn. Expand All opens only loaded folders. Co-Authored-By: Claude Opus 5.5 --- .changeset/files-panel-lazy.md | 5 + CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 + docs/wiki/Working-With-Files.md | 8 + src/web/public/i18n.js | 15 ++ src/web/public/index.html | 8 +- src/web/public/panels-ui.js | 253 +++++++++++++++++++++++++++-- src/web/public/styles.css | 23 +++ src/web/routes/file-routes.ts | 109 ++++++++++++- test/file-browser-search.test.ts | 173 +++++++++++++++++++- test/routes/file-tree-lazy.test.ts | 100 ++++++++++++ 11 files changed, 673 insertions(+), 25 deletions(-) create mode 100644 .changeset/files-panel-lazy.md create mode 100644 test/routes/file-tree-lazy.test.ts diff --git a/.changeset/files-panel-lazy.md b/.changeset/files-panel-lazy.md new file mode 100644 index 000000000..cfcf899cc --- /dev/null +++ b/.changeset/files-panel-lazy.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Files panel loads one folder at a time (expanding a folder fetches just that folder), sorts by name or modified time, and filters loaded entries locally while typing; Enter runs the whole-workspace search. diff --git a/CLAUDE.md b/CLAUDE.md index 7b6f9f363..0838f6e3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -221,7 +221,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **File Viewer edit mode** (issue #212): the file-preview overlay edits workspace text files in place — `GET .../file-content?edit=1` + `PUT /api/sessions/:id/file-content`, policy in `src/config/file-editing.ts`. This is a **third file surface and the only one that WRITES**: read-path confinement (realpath + workspace + ownership) plus sensitive/blocked/`.git` denies and an extension **allowlist**; writes are `wx`-temp + rename (no `O_CREAT` anywhere = edit-in-place is structural); optimistic concurrency via sha256 `baseHash` → 409. ⚠️ `edit=1` never truncates and the client must never save a plain-preview buffer (the 500-line truncation would silently delete the rest). ⚠️ CRLF/UTF-8 guards: EOL re-applied server-side, non-UTF-8 refused via round-trip compare. → [architecture-invariants#file-viewer-edit-mode](docs/architecture-invariants.md#file-viewer-edit-mode), `docs/file-viewer-edit-plan.md` -**Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search) +**Files panel lazy tree + search** (`?lazy=1&path=` lists ONE folder; typing filters loaded nodes locally, the server search below runs on Enter only; → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search)). **Server search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure) compiles the query into a predicate the server-side walk prunes with; a query returns a FLAT match list and the walk recurses past non-matching directories. An empty, whitespace-only or overlong (`MAX_QUERY_LENGTH`, 256) query compiles to `null`, keeping the default tree response byte-identical. ⚠️ **Never compile a glob into a RegExp** (`*a*a*a…` backtracks and freezes the event loop for the whole server): `globMatch()` is a two-pointer wildcard walk. → [architecture-invariants#files-panel-search](docs/architecture-invariants.md#files-panel-search) **Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate). diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 3e480185a..fd228e67e 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -661,6 +661,8 @@ Frontend `readmymind-ui.js`: header 🧠 marker-hidden (`btn-readmymind--hidden` ### Files panel search +**The tree is LAZY** (`?lazy=1[&path=]` on `GET /api/sessions/:id/files`): one directory per request, folders WITHOUT `children` (= not loaded; the client fetches on expand, `_loadFileBrowserChildren`, epoch-guarded like the root load), every entry with `mtimeMs` for the per-device sort (`codeman:fileBrowserSort`). The depth-5 walk took 14 s for a 5,000-node workspace on WSL `/mnt/d` (9P, a stat per entry); one folder takes well under a second. `path` resolves through `validateSessionFilePath()` (realpath, workspace-confined), a hidden segment needs `showHidden`, and the generated-directory excludes do not apply since nothing is walked. ⚠️ Typing in the search box is a LOCAL filter over already-loaded nodes (`filterFileBrowserLoaded`, ordered-substring `*` match, never a RegExp); the server search below runs only on Enter (`onFileBrowserSearchKeydown` → `filterFileBrowser`), and a folder hit the lazy tree has not loaded is opened by loading each ancestor (`_revealLazyFileBrowserDirectory`). Expand All opens only loaded folders, so it never fans out into a request per folder. The non-lazy tree response is unchanged. Tests: `test/routes/file-tree-lazy.test.ts`, the lazy block of `test/file-browser-search.test.ts`. + **Files panel search** (COD-236, the `q` param on `GET /api/sessions/:id/files`): `compileFileQuery()` (`utils/file-query.ts`, pure, no IO, so it unit-tests directly) compiles the query into a reusable predicate, which is what lets the server-side walk prune instead of streaming the whole tree. ⚠️ **A query turns that endpoint into a FLAT match list rather than a nested tree**, and the walk deliberately recurses past non-matching directories, since the whole point of searching is to reach a file whose ancestors do not match. An empty or whitespace-only query compiles to `null`, which is what keeps the default tree response byte-identical when no search is requested. diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index a691b3fa1..6f956b34b 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -9,6 +9,14 @@ until you get home. A panel that browses the active session's working directory. Its header button is on by default; if it is missing, re-enable it in **App Settings → Header & Panels**. +The tree loads one folder at a time: opening the panel lists the workspace's top level, and +expanding a folder fetches just that folder, so a large workspace on a slow mount (WSL +`/mnt/c`, a network share) opens in well under a second instead of waiting for a walk of +the whole tree. The sort menu beside the search box orders each folder by name or by +modified time (newest first finds the file an agent just wrote), folders always first, and +is remembered per device. Typing in the search box filters what is already loaded, in your +browser, with no request; press **Enter** to search the whole workspace on the server. + It renders what it can: | Kind | Behaviour | diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 9d9df44d8..d4c65cee6 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -59,6 +59,15 @@ '\u2B06 Upload': '\u2B06 上传', 'Insert Path': '插入路径', 'Quick browser: browse any allowed folder, upload and download files': '快速浏览:浏览允许的文件夹,上传和下载文件', + 'Filter loaded · Enter: whole workspace': '筛选已加载 · 回车搜索整个工作区', + 'Typing filters the folders already loaded; press Enter to search the whole workspace': + '输入时只筛选已加载的文件夹;按回车搜索整个工作区', + 'Sort files': '文件排序', + 'Name A→Z': '名称 A→Z', + 'Name Z→A': '名称 Z→A', + 'Newest first': '最新优先', + 'Oldest first': '最旧优先', + 'No matches in loaded folders': '已加载的文件夹中没有匹配项', 'Skip to terminal': '跳转到终端', 'Go to main page': '返回主页', 'Session tabs': '会话标签页', @@ -1256,6 +1265,12 @@ [/^Upload failed: (.+)$/, (_m, error) => `上传失败:${error}`], [/^Uploaded (\d+) files$/, (_m, n) => `已上传 ${n} 个文件`], [/^Uploaded (.+)$/, (_m, path) => `已上传 ${path}`], + [ + /^(\d+) match(?:es)? in loaded folders · Enter searches the whole workspace$/, + (_m, n) => `已加载文件夹中 ${n} 个匹配 · 回车搜索整个工作区`, + ], + [/^(\d+) files, (\d+) dirs loaded(.*)$/, (_m, f, d, rest) => `已加载 ${f} 个文件、${d} 个文件夹${rest}`], + [/^Failed to load: (.+)$/, (_m, error) => `加载失败:${error}`], [/^Failed to (.+)$/, (_m, action) => `操作失败:${action}`], [/^Will create: (.+)$/, (_m, path) => `将创建:${path}`], // The spreadsheet preview: an HTTP status, and the notice bar's counts. diff --git a/src/web/public/index.html b/src/web/public/index.html index a3e7233c4..5ddf609f5 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -588,7 +588,13 @@

Resume Conversation

Select a session to view files
diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 2eb203244..914b8f4c0 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -15,6 +15,9 @@ const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed'; const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden'; +// Per-device like the path picker's sort, and the same four modes. +const FILE_BROWSER_SORT_KEY = 'codeman:fileBrowserSort'; +const FILE_BROWSER_SORT_MODES = ['name-asc', 'name-desc', 'mtime-desc', 'mtime-asc']; // Bounds for the by-id text preview, mirroring what the workspace text preview // already does server-side (500 lines). The byte cap rides a Range request, so // a huge log is a partial read rather than a download the viewer throws away. @@ -3160,6 +3163,7 @@ Object.assign(CodemanApp.prototype, { state.view = 'normal'; this.fileBrowserData = null; this.fileBrowserFilter = ''; + this.fileBrowserLocalFilter = ''; this.fileBrowserExpandedDirs?.clear?.(); this.fileBrowserAllExpanded = false; @@ -3202,6 +3206,7 @@ Object.assign(CodemanApp.prototype, { state.view = 'normal'; this.fileBrowserData = null; this.fileBrowserFilter = ''; + this.fileBrowserLocalFilter = ''; this.fileBrowserExpandedDirs?.clear?.(); this.fileBrowserAllExpanded = false; @@ -3237,7 +3242,8 @@ Object.assign(CodemanApp.prototype, { const statusEl = this.$('fileBrowserStatus'); if (!statusEl || !data) return; const { totalFiles, totalDirectories, truncated } = data; - statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`; + const loaded = data.lazy ? ' loaded' : ''; + statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${loaded}${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`; }, _isFileBrowserNormalCompatible(candidate, sessionId, showHidden, treeEpoch) { @@ -3466,6 +3472,8 @@ Object.assign(CodemanApp.prototype, { const state = this._ensureFileBrowserState(); const treeEl = this.$('fileBrowserTree'); this._syncFileBrowserHiddenBtn(); + const sortSelect = this.$?.('fileBrowserSort'); + if (sortSelect) sortSelect.value = this._fileBrowserSortMode(); if (!treeEl) return undefined; if (!state.ownerSessionId) state.ownerSessionId = sessionId; if (state.ownerSessionId !== sessionId) return undefined; @@ -3501,7 +3509,7 @@ Object.assign(CodemanApp.prototype, { const request = (async () => { try { const res = await fetch( - `/api/sessions/${encodeURIComponent(sessionId)}/files?depth=5&showHidden=${showHidden}`, + `/api/sessions/${encodeURIComponent(sessionId)}/files?lazy=1&showHidden=${showHidden}`, ); if (!res.ok) throw new Error('Failed to load files'); const result = await res.json(); @@ -3557,10 +3565,32 @@ Object.assign(CodemanApp.prototype, { } const html = []; + // Local filter: only what is already loaded is searched (never a server walk). + // A node shows when it matches or a loaded descendant does; matched folders' + // ancestors render open so the hit is in context. + const filterParts = this._fileBrowserLocalFilterParts(); + const filtering = filterParts.length > 0; + let filterMatches = 0; + const visible = new Set(); + if (filtering) { + const mark = (node) => { + let keep = this._fileBrowserNameMatches(node.name, filterParts); + if (keep) filterMatches++; + if (node.type === 'directory' && Array.isArray(node.children)) { + for (const child of node.children) if (mark(child)) keep = true; + } + if (keep) visible.add(node); + return keep; + }; + for (const node of tree) mark(node); + } + const pendingLoads = []; const renderNode = (node, depth) => { + if (filtering && !visible.has(node)) return; const isDir = node.type === 'directory'; - const isExpanded = this.fileBrowserExpandedDirs.has(node.path); + const hasVisibleChild = filtering && isDir && Array.isArray(node.children) && node.children.some((c) => visible.has(c)); + const isExpanded = filtering ? hasVisibleChild : this.fileBrowserExpandedDirs.has(node.path); const icon = isDir ? (isExpanded ? '\uD83D\uDCC2' : '\uD83D\uDCC1') @@ -3590,19 +3620,32 @@ Object.assign(CodemanApp.prototype, {
`); - // Render children if directory is expanded - if (isDir && isExpanded && node.children) { - for (const child of node.children) { - renderNode(child, depth + 1); + // Render children if directory is expanded. `children` undefined = not + // loaded yet (lazy listing): fetch it, showing a placeholder row meanwhile. + if (isDir && isExpanded) { + if (Array.isArray(node.children)) { + for (const child of this._sortFileBrowserNodes(node.children)) { + renderNode(child, depth + 1); + } + } else if (!filtering) { + const label = node._loadError ? `Failed to load: ${node._loadError}` : 'Loading...'; + html.push(`
${escapeHtml(label)}
`); + if (!node._loading && !node._loadError) pendingLoads.push(node); } } }; - for (const node of tree) { + for (const node of this._sortFileBrowserNodes(tree)) { renderNode(node, 0); } - treeEl.innerHTML = html.join(''); + treeEl.innerHTML = html.join('') || '
No matches in loaded folders
'; + if (filtering) { + const statusEl = this.$('fileBrowserStatus'); + if (statusEl) { + statusEl.textContent = `${filterMatches} ${filterMatches === 1 ? 'match' : 'matches'} in loaded folders · Enter searches the whole workspace`; + } + } // Add click handlers treeEl.querySelectorAll('.file-tree-item').forEach(item => { @@ -3611,17 +3654,166 @@ Object.assign(CodemanApp.prototype, { const type = item.dataset.type; if (type === 'directory') { - this.toggleFileBrowserFolder(path); + if (filtering) this._jumpFileBrowserToDirectory(path); + else this.toggleFileBrowserFolder(path); } else { this.openFilePreview(path, owner); } }); }); + for (const node of pendingLoads) this._loadFileBrowserChildren(node, owner); + }, + + _fileBrowserSortMode() { + if (!this._fileBrowserSort) { + let stored = null; + try { + stored = localStorage.getItem(FILE_BROWSER_SORT_KEY); + } catch {} + this._fileBrowserSort = FILE_BROWSER_SORT_MODES.includes(stored) ? stored : 'name-asc'; + } + return this._fileBrowserSort; + }, + + setFileBrowserSort(mode) { + if (!FILE_BROWSER_SORT_MODES.includes(mode)) return; + this._fileBrowserSort = mode; + try { + localStorage.setItem(FILE_BROWSER_SORT_KEY, mode); + } catch {} + const select = this.$?.('fileBrowserSort'); + if (select && select.value !== mode) select.value = mode; + if (this._ensureFileBrowserState().view === 'normal' && this.fileBrowserData) this.renderFileBrowserTree(); + }, + + /** Folders first in every mode, like the path picker; a missing mtime sorts last, then by name. */ + _sortFileBrowserNodes(nodes) { + const [key, direction] = this._fileBrowserSortMode().split('-'); + const sign = direction === 'desc' ? -1 : 1; + const byName = (a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + return nodes.slice().sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + if (key === 'mtime') { + const at = typeof a.mtimeMs === 'number' ? a.mtimeMs : null; + const bt = typeof b.mtimeMs === 'number' ? b.mtimeMs : null; + if (at !== null && bt !== null && at !== bt) return sign * (at - bt); + if (at === null && bt !== null) return 1; + if (at !== null && bt === null) return -1; + return byName(a, b); + } + return sign * byName(a, b); + }); + }, + + /** Lowercased `*`-separated parts of the local filter (empty = no filter). */ + _fileBrowserLocalFilterParts() { + const query = String(this.fileBrowserLocalFilter || '').trim().toLowerCase(); + return query ? query.split('*').filter(Boolean) : []; + }, + + /** Ordered substring match, linear: `*` is a wildcard, never a RegExp. */ + _fileBrowserNameMatches(name, parts) { + const lower = String(name).toLowerCase(); + let from = 0; + for (const part of parts) { + const at = lower.indexOf(part, from); + if (at < 0) return false; + from = at + part.length; + } + return true; + }, + + /** + * Fetch one folder's entries for the lazy tree and attach them as `children`. + * Dropped when anything that scopes the tree changed meanwhile (owner, refresh, + * hidden toggle), the same epoch rules the root load uses. + */ + _loadFileBrowserChildren(node, ownerSessionId) { + const state = this._ensureFileBrowserState(); + const data = this.fileBrowserData; + if (!node || node._loading || !data || !ownerSessionId) return undefined; + const context = { sessionId: ownerSessionId, treeEpoch: state.treeEpoch, showHidden: this.fileBrowserShowHidden === true }; + node._loading = true; + node._loadError = ''; + const url = `/api/sessions/${encodeURIComponent(ownerSessionId)}/files?lazy=1&showHidden=${context.showHidden}&path=${encodeURIComponent(node.path)}`; + return (async () => { + try { + const res = await fetch(url); + const result = await res.json().catch(() => null); + const listing = res.ok ? this._validateFileBrowserTreeEnvelope(result) : null; + if (!listing) throw new Error(result?.error || 'Failed to load folder'); + if (!this._isFileBrowserTreeContextCurrent(context) || this.fileBrowserData !== data) return; + node.children = listing.tree; + data.totalFiles += listing.totalFiles; + data.totalDirectories += listing.totalDirectories; + if (listing.truncated) data.truncated = true; + } catch (error) { + if (!this._isFileBrowserTreeContextCurrent(context) || this.fileBrowserData !== data) return; + node._loadError = this._normalizeFileBrowserTreeError(error); + } finally { + node._loading = false; + } + if (state.view === 'normal' && this.activeSessionId === ownerSessionId) { + this.renderFileBrowserTree(ownerSessionId); + if (!this._fileBrowserLocalFilterParts().length) this._renderFileBrowserNormalStatus(data, context.showHidden); + } + })(); + }, + + /** From a local-filter hit: clear the filter and open the folder in the full tree. */ + _jumpFileBrowserToDirectory(path) { + const found = this._findFileBrowserDirectory(this.fileBrowserData?.tree, path); + this.fileBrowserLocalFilter = ''; + const input = this.$?.('fileBrowserSearch'); + if (input) input.value = ''; + this._syncFileBrowserExpandBtn(); + if (found) for (const p of [...found.ancestors, path]) this.fileBrowserExpandedDirs.add(p); + this.renderFileBrowserTree(); + if (this.fileBrowserData) this._renderFileBrowserNormalStatus(this.fileBrowserData, this.fileBrowserShowHidden === true); + }, + + /** + * Typing in the Files search box filters what is already loaded, in the + * browser, with no request: a workspace-wide search walks the whole tree on + * the server (seconds on a slow mount), so it only runs on Enter + * (filterFileBrowser). Typing again leaves any server results behind. + */ + filterFileBrowserLoaded(value) { + const state = this._ensureFileBrowserState(); + if (state.view !== 'normal' || state.inFlight) { + if (state.inFlight?.timer !== undefined && state.inFlight?.timer !== null) clearTimeout(state.inFlight.timer); + state.inFlight = null; + state.searchEpoch++; + state.filter = ''; + state.matches = []; + state.deferredDirectoryTarget = null; + state.view = 'normal'; + this.fileBrowserFilter = ''; + } + this.fileBrowserLocalFilter = String(value ?? ''); + this._syncFileBrowserExpandBtn(); + const normal = state.normalState; + if (normal?.phase === 'ready' && this._canRenderFileBrowserNormal(normal)) { + this.fileBrowserData = normal.data; + this.renderFileBrowserTree(normal.sessionId); + if (!this._fileBrowserLocalFilterParts().length) this._renderFileBrowserNormalStatus(normal.data, normal.showHidden); + } + }, + + /** Enter in the Files search box: the explicit, workspace-wide server search. */ + onFileBrowserSearchKeydown(event) { + if (event?.key !== 'Enter' || event.isComposing) return; + event.preventDefault?.(); + this.fileBrowserLocalFilter = ''; + this.filterFileBrowser(event.target?.value ?? ''); }, toggleFileBrowserFolder(path) { if (this.fileBrowserExpandedDirs.has(path)) { this.fileBrowserExpandedDirs.delete(path); + // Collapsing a folder whose lazy load failed lets the next expand retry. + const found = this._findFileBrowserDirectory(this.fileBrowserData?.tree, path); + if (found?.target?._loadError) found.target._loadError = ''; } else { this.fileBrowserExpandedDirs.add(path); } @@ -3852,7 +4044,8 @@ Object.assign(CodemanApp.prototype, { const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path); if (!found) { - this._promptFileBrowserDirectoryReload(); + if (normalState.data?.lazy) this._revealLazyFileBrowserDirectory(target.path, normalState); + else this._promptFileBrowserDirectoryReload(); return; } this._leaveFileBrowserSearchForDirectory([...found.ancestors, found.target.path], normalState); @@ -3886,6 +4079,10 @@ Object.assign(CodemanApp.prototype, { const found = this._findFileBrowserDirectory(normalState.data?.tree, target.path); if (!found) { + if (normalState.data?.lazy) { + this._revealLazyFileBrowserDirectory(target.path, normalState); + return true; + } this._promptFileBrowserDirectoryReload(); return false; } @@ -3916,9 +4113,40 @@ Object.assign(CodemanApp.prototype, { this._renderFileBrowserNormalState(normalState); }, + // Lazy tree: only folders already loaded open, so Expand never fans out into a + // request per folder. + /** + * A server-search hit can name a folder the lazy tree has not loaded yet: leave + * search, then load and open each ancestor in turn (one listing per level). The + * server builds every relative path with '/', so the chain is its prefixes. + */ + async _revealLazyFileBrowserDirectory(path, normalState) { + this._leaveFileBrowserSearchForDirectory([], normalState); + const data = normalState.data; + const owner = normalState.sessionId; + const segments = String(path).split('/').filter(Boolean); + let nodes = data.tree; + for (let i = 0; i < segments.length; i++) { + const prefix = segments.slice(0, i + 1).join('/'); + const node = Array.isArray(nodes) ? nodes.find((n) => n.type === 'directory' && n.path === prefix) : null; + if (!node || this.fileBrowserData !== data) { + this._promptFileBrowserDirectoryReload(); + return; + } + this.fileBrowserExpandedDirs.add(prefix); + if (node.children === undefined) { + const load = this._loadFileBrowserChildren(node, owner); + if (load) await load; + if (!Array.isArray(node.children)) return; + } + nodes = node.children; + } + this.renderFileBrowserTree(owner); + }, + expandAllDirectories(nodes) { for (const node of nodes) { - if (node.type === 'directory') { + if (node.type === 'directory' && node.children !== undefined) { this.fileBrowserExpandedDirs.add(node.path); if (node.children) { this.expandAllDirectories(node.children); @@ -3961,6 +4189,7 @@ Object.assign(CodemanApp.prototype, { state.deferredDirectoryTarget = null; state.view = 'normal'; this.fileBrowserFilter = ''; + this.fileBrowserLocalFilter = ''; this.fileBrowserExpandedDirs.clear(); this.fileBrowserAllExpanded = false; const expandBtn = this.$('fileBrowserExpandBtn'); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index cbf664d58..aab867867 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -11360,12 +11360,16 @@ kbd { } .file-browser-search { + display: flex; + gap: 0.3rem; padding: 0.4rem; border-bottom: 1px solid var(--border); flex-shrink: 0; } .file-browser-search input { + flex: 1; + min-width: 0; width: 100%; padding: 0.3rem 0.5rem; font-size: 0.75rem; @@ -11380,6 +11384,25 @@ kbd { border-color: var(--accent); } +.file-browser-sort { + flex: 0 0 auto; + max-width: 7.5rem; + padding: 0.2rem 0.3rem; + font-size: 0.7rem; + color: var(--text); + background: var(--bg-dark); + border: 1px solid var(--border); + border-radius: 4px; +} + +/* Lazy tree: the row under an expanded folder while its listing loads (or failed). */ +.file-tree-placeholder { + padding: 0.2rem 0.5rem; + color: var(--text-dim); + font-size: 0.7rem; + font-style: italic; +} + .file-browser-body { flex: 1; overflow-y: auto; diff --git a/src/web/routes/file-routes.ts b/src/web/routes/file-routes.ts index e8def141b..afdc7d6fb 100644 --- a/src/web/routes/file-routes.ts +++ b/src/web/routes/file-routes.ts @@ -769,6 +769,8 @@ type AttachmentHistoryRouteItem = Omit { + app.get('/api/sessions/:id/files', async (req, reply) => { const { id } = req.params as { id: string }; - const { depth, showHidden, q } = req.query as { depth?: string; showHidden?: string; q?: string }; + const { + depth, + showHidden, + q, + lazy, + path: lazyPath, + } = req.query as { + depth?: string; + showHidden?: string; + q?: string; + lazy?: string; + path?: string; + }; const session = findSessionOrFail(ctx, id, req); + // ===== Lazy mode ===== + // `?lazy=1[&path=]` lists ONE directory, the way the + // path picker browses: on a slow mount (WSL /mnt/ is 9P, a stat per + // entry) the depth-5 walk below took 14 s for a 5,000-node workspace, one + // folder takes well under a second. Directories come back WITHOUT `children` + // (= not loaded yet; the client fetches them on expand), every entry carries + // `mtimeMs` for date sorting, and the generated-directory excludes do not + // apply: nothing below is walked, so node_modules costs one row, not a scan. + if (lazy === '1' || lazy === 'true') { + const includeHidden = showHidden === 'true'; + const relativeDir = typeof lazyPath === 'string' ? lazyPath : ''; + const resolved = validateSessionFilePath(session.workingDir, relativeDir || '.'); + if (!resolved) { + reply.code(404); + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Folder not found in this workspace'); + } + if (!includeHidden && relativeDir.split(/[\\/]/).some((segment) => segment.startsWith('.'))) { + reply.code(403); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Hidden folders are not shown'); + } + let entries; + try { + entries = await fs.readdir(resolved.resolvedPath, { withFileTypes: true }); + } catch { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read'); + } + const base = relativeDir.replace(/[\\/]+$/, ''); + const nodes: Array<{ + name: string; + path: string; + type: 'file' | 'directory'; + size?: number; + extension?: string; + mtimeMs?: number; + }> = []; + let files = 0; + let directories = 0; + let lazyTruncated = false; + for (const entry of entries) { + if (!includeHidden && entry.name.startsWith('.')) continue; + if (nodes.length >= LAZY_FILE_TREE_ENTRY_LIMIT) { + lazyTruncated = true; + break; + } + const fullPath = join(resolved.resolvedPath, entry.name); + let isDirectory = entry.isDirectory(); + let stat: import('node:fs').Stats | undefined; + try { + // stat (not lstat): a symlinked folder lists as a folder, and its + // children are re-confined to the workspace when it is expanded. + stat = await fs.stat(fullPath); + isDirectory = stat.isDirectory(); + } catch { + if (entry.isSymbolicLink()) continue; // dangling link + } + if (!isDirectory && !entry.isFile() && !stat?.isFile()) continue; + const path = base ? `${base}/${entry.name}` : entry.name; + if (isDirectory) { + directories++; + nodes.push({ name: entry.name, path, type: 'directory', mtimeMs: stat?.mtimeMs }); + } else { + files++; + nodes.push({ + name: entry.name, + path, + type: 'file', + size: stat?.size, + extension: entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined, + mtimeMs: stat?.mtimeMs, + }); + } + } + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === 'directory' ? -1 : 1; + return a.name.localeCompare(b.name); + }); + return { + success: true, + data: { + root: session.workingDir, + path: base, + lazy: true, + tree: nodes, + totalFiles: files, + totalDirectories: directories, + truncated: lazyTruncated, + }, + }; + } + const maxDepth = Math.min(parseInt(depth || '5', 10), 10); const includeHidden = showHidden === 'true'; const workingDir = session.workingDir; diff --git a/test/file-browser-search.test.ts b/test/file-browser-search.test.ts index 81519b26d..d8545ad2b 100644 --- a/test/file-browser-search.test.ts +++ b/test/file-browser-search.test.ts @@ -786,7 +786,7 @@ describe('File Viewer server search', () => { expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts'); expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts'); - expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true'); + expect(pending[1].url).toBe('/api/sessions/session%2FA/files?lazy=1&showHidden=true'); pending[1].reply.resolve(response(successfulTree('new-preference.ts'))); await toggle; @@ -877,8 +877,8 @@ describe('File Viewer server search', () => { const firstToggle = app.toggleFileBrowserHidden(); const secondToggle = app.toggleFileBrowserHidden(); expect(pending.slice(1).map(({ url }) => url)).toEqual([ - '/api/sessions/session%2FA/files?depth=5&showHidden=true', - '/api/sessions/session%2FA/files?depth=5&showHidden=false', + '/api/sessions/session%2FA/files?lazy=1&showHidden=true', + '/api/sessions/session%2FA/files?lazy=1&showHidden=false', ]); pending[1].reply.resolve(response(successfulTree('stale-hidden.ts'))); @@ -1674,7 +1674,7 @@ describe('File Viewer server search', () => { expect(immediateState.activeSessionId).toBe('session/B'); expect(immediateState.ownerSessionId).toBe('session/B'); expect(immediateState.resizeCalls).toBe(1); - expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']); + expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?lazy=1&showHidden=false']); expect(filePending).toHaveLength(1); expect(app._fileBrowserState.ownerSessionId).toBe('session/B'); @@ -1755,7 +1755,7 @@ describe('File Viewer server search', () => { expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E'); expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0); expect(elements.fileBrowserTree.innerHTML).toContain('Loading files'); - expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']); + expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?lazy=1&showHidden=false']); await vi.advanceTimersByTimeAsync(250); expect(pending).toHaveLength(1); @@ -1797,7 +1797,7 @@ describe('File Viewer server search', () => { await vi.advanceTimersByTimeAsync(250); expect(pending.map(({ url }) => url)).toEqual([ - '/api/sessions/session%2FB/files?depth=5&showHidden=false', + '/api/sessions/session%2FB/files?lazy=1&showHidden=false', '/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load', ]); expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true); @@ -1815,9 +1815,9 @@ describe('File Viewer server search', () => { expect(secondA).not.toBe(firstA); expect(pending.map(({ url }) => url)).toEqual([ - '/api/sessions/session%2FA/files?depth=5&showHidden=false', - '/api/sessions/session%2FB/files?depth=5&showHidden=false', - '/api/sessions/session%2FA/files?depth=5&showHidden=false', + '/api/sessions/session%2FA/files?lazy=1&showHidden=false', + '/api/sessions/session%2FB/files?lazy=1&showHidden=false', + '/api/sessions/session%2FA/files?lazy=1&showHidden=false', ]); pending[0].reply.resolve(response(successfulTree('stale-A.ts'))); @@ -1978,3 +1978,158 @@ describe('File Viewer server search', () => { }); }); }); + +describe('File Viewer lazy tree, sort and local filter', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + const lazyTree = (tree: unknown[], overrides: Record = {}) => ({ + success: true, + data: { + lazy: true, + tree, + totalFiles: tree.filter((n: any) => n.type === 'file').length, + totalDirectories: tree.filter((n: any) => n.type === 'directory').length, + truncated: false, + ...overrides, + }, + }); + const dir = (path: string, mtimeMs?: number) => ({ name: path.split('/').pop(), path, type: 'directory', mtimeMs }); + const file = (path: string, mtimeMs?: number) => ({ + name: path.split('/').pop(), + path, + type: 'file', + size: 1, + extension: 'ts', + mtimeMs, + }); + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + async function loadedRoot(tree: unknown[]) { + const harness = loadPanel(); + const load = harness.app.loadFileBrowser('session/A'); + expect(harness.pending[0].url).toBe('/api/sessions/session%2FA/files?lazy=1&showHidden=false'); + harness.pending[0].reply.resolve(response(lazyTree(tree))); + await load; + return harness; + } + + it('loads a folder only when it is expanded, once, with a loading row meanwhile', async () => { + const { app, elements, pending } = await loadedRoot([dir('src'), file('README.md')]); + expect(elements.fileBrowserTree.innerHTML).toContain('README.md'); + expect(pending).toHaveLength(1); + + app.toggleFileBrowserFolder('src'); + expect(pending).toHaveLength(2); + expect(pending[1].url).toBe('/api/sessions/session%2FA/files?lazy=1&showHidden=false&path=src'); + expect(elements.fileBrowserTree.innerHTML).toContain('Loading...'); + + pending[1].reply.resolve(response(lazyTree([file('src/app.ts')]))); + await flush(); + expect(elements.fileBrowserTree.innerHTML).toContain('app.ts'); + expect(elements.fileBrowserTree.innerHTML).not.toContain('Loading...'); + expect(elements.fileBrowserStatus.textContent).toContain('2 files, 1 dirs loaded'); + + app.toggleFileBrowserFolder('src'); + app.toggleFileBrowserFolder('src'); + expect(pending).toHaveLength(2); + }); + + it('drops a folder listing that lands after a refresh', async () => { + const { app, elements, pending } = await loadedRoot([dir('src')]); + app.toggleFileBrowserFolder('src'); + app.refreshFileBrowser(); + pending[1].reply.resolve(response(lazyTree([file('src/stale.ts')]))); + await flush(); + expect(elements.fileBrowserTree.innerHTML).not.toContain('stale.ts'); + }); + + it('shows a failed folder load and retries after collapse and expand', async () => { + const { app, elements, pending } = await loadedRoot([dir('src')]); + app.toggleFileBrowserFolder('src'); + pending[1].reply.resolve(response({ success: false, error: 'This folder cannot be read' }, false)); + await flush(); + expect(elements.fileBrowserTree.innerHTML).toContain('Failed to load: This folder cannot be read'); + app.toggleFileBrowserFolder('src'); + app.toggleFileBrowserFolder('src'); + expect(pending).toHaveLength(3); + }); + + it('sorts by date with folders first, and remembers the choice', async () => { + const { app, elements } = await loadedRoot([ + file('old.ts', 100), + file('new.ts', 300), + dir('zeta', 50), + file('undated.ts'), + file('mid.ts', 200), + ]); + app.setFileBrowserSort('mtime-desc'); + const html = elements.fileBrowserTree.innerHTML; + const order = ['zeta', 'new.ts', 'mid.ts', 'old.ts', 'undated.ts'].map((name) => html.indexOf(`>${name}<`)); + expect(order.every((at) => at >= 0)).toBe(true); + expect([...order].sort((a, b) => a - b)).toEqual(order); + expect(app._fileBrowserSort).toBe('mtime-desc'); + + app.setFileBrowserSort('name-desc'); + const byName = elements.fileBrowserTree.innerHTML; + expect(byName.indexOf('>undated.ts<')).toBeLessThan(byName.indexOf('>new.ts<')); + expect(byName.indexOf('>zeta<')).toBeLessThan(byName.indexOf('>undated.ts<')); + }); + + it('filters only loaded entries locally, with no request, and Enter runs the workspace search', async () => { + const { app, elements, pending } = await loadedRoot([dir('src'), dir('docs'), file('widget-root.ts')]); + app.toggleFileBrowserFolder('src'); + pending[1].reply.resolve(response(lazyTree([file('src/widget.ts'), file('src/other.ts')]))); + await flush(); + app.toggleFileBrowserFolder('src'); + + elements.fileBrowserSearch.value = 'wid*ts'; + app.filterFileBrowserLoaded('wid*ts'); + await vi.advanceTimersByTimeAsync(300); + expect(pending).toHaveLength(2); + const html = elements.fileBrowserTree.innerHTML; + expect(html).toContain('widget.ts'); + expect(html).toContain('widget-root.ts'); + expect(html).not.toContain('other.ts'); + expect(html).not.toContain('>docs<'); + expect(elements.fileBrowserStatus.textContent).toBe( + '2 matches in loaded folders · Enter searches the whole workspace' + ); + + app.onFileBrowserSearchKeydown({ key: 'Enter', target: { value: 'widget' }, preventDefault: vi.fn() }); + await vi.advanceTimersByTimeAsync(250); + expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget'); + + app.filterFileBrowserLoaded(''); + expect(app._fileBrowserState.view).toBe('normal'); + expect(elements.fileBrowserTree.innerHTML).toContain('>docs<'); + }); + + it('opens a server-search folder the lazy tree has not loaded by loading each ancestor', async () => { + const { app, elements, pending } = await loadedRoot([dir('a')]); + elements.fileBrowserSearch.value = 'deep'; + app.filterFileBrowser('deep'); + await vi.advanceTimersByTimeAsync(250); + pending[1].reply.resolve( + response(successfulData({ matches: [{ name: 'deep', path: 'a/b/deep', type: 'directory' }], matchCount: 1 })) + ); + await flush(); + elements.fileBrowserTree.querySelectorAll('.file-tree-item')[0].click(); + await flush(); + expect(pending[2].url).toContain('&path=a'); + pending[2].reply.resolve(response(lazyTree([dir('a/b')]))); + await flush(); + expect(pending[3].url).toContain('&path=a%2Fb'); + pending[3].reply.resolve(response(lazyTree([dir('a/b/deep')]))); + await flush(); + await flush(); + expect(pending[4].url).toContain('&path=a%2Fb%2Fdeep'); + expect(app.fileBrowserExpandedDirs.has('a/b/deep')).toBe(true); + expect(app._fileBrowserState.view).toBe('normal'); + }); +}); diff --git a/test/routes/file-tree-lazy.test.ts b/test/routes/file-tree-lazy.test.ts new file mode 100644 index 000000000..9b2b11cc4 --- /dev/null +++ b/test/routes/file-tree-lazy.test.ts @@ -0,0 +1,100 @@ +/** + * @fileoverview `GET /api/sessions/:id/files?lazy=1[&path=]`: the File Viewer's + * one-directory listing. Pins that it lists exactly one level (folders without + * `children`), carries `mtimeMs` for date sorting, stays confined to the + * workspace (traversal and symlink escapes), honours the hidden opt-in, and does + * not change the default (non-lazy) tree response. + * + * Real temp directory, app.inject(), no port. + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createMockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerFileRoutes } from '../../src/web/routes/file-routes.js'; + +describe('lazy File Viewer listing', () => { + let app: FastifyInstance; + let ctx: ReturnType; + let workspace: string; + let outside: string; + + beforeEach(async () => { + workspace = await realpath(await mkdtemp(join(tmpdir(), 'codeman-lazy-tree-'))); + outside = await realpath(await mkdtemp(join(tmpdir(), 'codeman-lazy-outside-'))); + await mkdir(join(workspace, 'src', 'deep'), { recursive: true }); + await mkdir(join(workspace, 'node_modules', 'pkg'), { recursive: true }); + await mkdir(join(workspace, '.git')); + await writeFile(join(workspace, 'README.md'), 'hello'); + await writeFile(join(workspace, 'src', 'app.ts'), 'x'); + await writeFile(join(workspace, 'src', 'deep', 'leaf.ts'), 'y'); + await writeFile(join(outside, 'secret.txt'), 'nope'); + app = Fastify({ logger: false }); + ctx = createMockRouteContext(); + ctx._session.workingDir = workspace; + registerFileRoutes(app, ctx as never); + installRouteErrorHandler(app); + await app.ready(); + }); + + afterEach(async () => { + await app.close(); + await rm(workspace, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + }); + + const list = async (query: string) => { + const res = await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/files?lazy=1${query}` }); + return { status: res.statusCode, body: JSON.parse(res.body) }; + }; + + it('lists one level: folders first, no children, mtime on every entry, generated folders included', async () => { + const { status, body } = await list(''); + expect(status).toBe(200); + expect(body.data.lazy).toBe(true); + expect(body.data.tree.map((n: { name: string }) => n.name)).toEqual(['node_modules', 'src', 'README.md']); + for (const node of body.data.tree) { + expect(node.children).toBeUndefined(); + expect(typeof node.mtimeMs).toBe('number'); + } + expect(body.data.tree[2]).toMatchObject({ path: 'README.md', type: 'file', size: 5, extension: 'md' }); + expect(body.data).toMatchObject({ totalFiles: 1, totalDirectories: 2, truncated: false }); + }); + + it('lists a subfolder with workspace-relative paths', async () => { + const { body } = await list('&path=src'); + expect(body.data.path).toBe('src'); + expect(body.data.tree.map((n: { path: string }) => n.path)).toEqual(['src/deep', 'src/app.ts']); + const deep = await list('&path=src/deep'); + expect(deep.body.data.tree.map((n: { path: string }) => n.path)).toEqual(['src/deep/leaf.ts']); + }); + + it('hides dot entries and hidden folders unless asked', async () => { + expect((await list('')).body.data.tree.some((n: { name: string }) => n.name === '.git')).toBe(false); + expect((await list('&path=.git')).status).toBe(403); + const shown = await list('&showHidden=true'); + expect(shown.body.data.tree.some((n: { name: string }) => n.name === '.git')).toBe(true); + }); + + it('refuses traversal and symlink escapes out of the workspace', async () => { + expect((await list(`&path=${encodeURIComponent('../')}`)).status).toBe(404); + await symlink(outside, join(workspace, 'escape')); + expect((await list('&path=escape')).status).toBe(404); + }); + + it('answers a missing folder with 404 and a file with 400', async () => { + expect((await list('&path=missing')).status).toBe(404); + expect((await list('&path=README.md')).status).toBe(400); + }); + + it('leaves the default tree response unchanged (nested, excludes generated folders)', async () => { + const res = await app.inject({ method: 'GET', url: `/api/sessions/${ctx._sessionId}/files?depth=5` }); + const data = JSON.parse(res.body).data; + expect(data.lazy).toBeUndefined(); + expect(data.tree.map((n: { name: string }) => n.name)).toEqual(['src', 'README.md']); + expect(data.tree[0].children.map((n: { name: string }) => n.name)).toEqual(['deep', 'app.ts']); + }); +});