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/.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..0838f6e3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,11 +217,11 @@ 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` -**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 1836d1bf5..fd228e67e 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. @@ -659,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 fe7835013..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 | @@ -148,10 +156,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 +172,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..d4c65cee6 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -55,6 +55,19 @@ '应用于新本地会话;可用强度取决于模型和 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': '快速浏览:浏览允许的文件夹,上传和下载文件', + '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': '会话标签页', @@ -1245,6 +1258,19 @@ [/^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}`], + [ + /^(\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 1e8e3da5e..5ddf609f5 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -580,6 +580,7 @@

Resume Conversation

Files
+ @@ -587,7 +588,13 @@

Resume Conversation

Select a session to view 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..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) { @@ -3405,6 +3411,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 : ''; @@ -3452,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; @@ -3487,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(); @@ -3543,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') @@ -3576,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 => { @@ -3597,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); } @@ -3838,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); @@ -3872,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; } @@ -3902,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); @@ -3947,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 f8efa7d75..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; @@ -15042,6 +15065,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..afdc7d6fb 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, @@ -761,6 +769,8 @@ type AttachmentHistoryRouteItem = Omit