mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #651: fix(uploads): say how many files a mixed pick, drop or paste skipped
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> # Conflicts: # CLAUDE.md
This commit is contained in:
commit
5481fd599b
12 files changed
+230
-46
No files matched your search
@@ -215,7 +215,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sLine truncated
|
||||
**Prompt uploads** (`POST /api/sessions/:id/paste-image`: clipboard paste, drop, the Compose Attach key and the phone toolbar's Attach button; client wiring in `image-input.js` and `keyboard-accessory.js`): a pick is written to `<workspace>/.codeman-uploads/` and its path lands on the prompt as unsent text. The folder is IN the workspace because that is the only path identical for a local and a container agent, FLAT because `<workspace>/.codeman/` would BE the data dir when the workspace is the home directory, and self-ignoring through a `.gitignore` of `*` written ONCE with `wx` (a file already there is the user's and is never overwritten; the user's own ignore file is never touched). ⚠️ The directory names live ONLY in `paste-image-gc.ts` (`UPLOAD_DIR_NAMES`): the pre-move `.claude-images/` receives nothing but stays readable for one release, so the GC sweep and the delete cleanup (through `uploadDirs()`) and the image watcher's ignore filter (reading the names) cover BOTH; retire the legacy entry there, never by hand elsewhere. ⚠️ `uploadDirs()` is async and probes the workspace BOUNDED first (`probePathKind()`, the #516 rule: an `unknown` path is skipped, never touched; the sweep keeps the stall cap and the delete passes `pastCap`), then returns only REAL directories (lstat, so a planted symlink is never one), none that is or contains `getDataDir()` (one strictly inside it only holds uploads and is listed), and none for a remote session, since both consumers delete; the check is made at list time, and the same-user race after it is accepted. Images are buffered in memory under `MAX_PASTE_IMAGE_BYTES` (50MB) after a client-side canvas re-encode; a DOCUMENT (pdf, docx, pptx, xlsx, zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml; the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`, which carry sh/py/js/html) is admitted by its name's extension alone, buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (`%PDF-`, `PK\x03\x04` for the OOXML trio and zip, UTF-8 without a NUL byte for text: a SIGNATURE or ENCODING check, never a structure or content check, so generic zip bytes pass as `.docx` and a script renamed `.txt` is text; the sh/py/js/html exclusion is a SCOPE choice by extension, keeping the server from storing what it would serve or run, not a content filter: the agent can already write any of those) and saved as `paste-<ts>-<hex>-<stem><ext>` with a sanitized stem (`[A-Za-z0-9._-]`, 40 chars, a wholly non-ASCII name leaves no stem; the `paste-` prefix is what the GC matches). A VIDEO is never buffered: `?kind=video` streams it to disk under `MAX_PASTE_VIDEO_BYTES` (1GB, env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), judges the container on the first 12 bytes as they arrive (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv; a mislabeled body is drained unwritten from there, never parked on disk first) and unlinks on any failure, a dead transfer included. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced: mp4/webm/mov/m4v/ogv, so an uploaded video is one the file viewer can also play; 3gp/mkv are out, since a phone's camera roll produces neither and neither previews), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. Busboy takes its size limit before the part is seen, which is why the choice rides the URL and not the file. ⚠️ A video draws on its OWN per-(IP, session) rate bucket (`consumeVideoToken`, 3/min) beside the image bucket (`consumePasteToken`, 30/min): at 1GB each, 30/min would be 30GB of disk a minute, so the lower rate bounds it to 3GB; the image path keeps the 30/min bucket and is unchanged without the flag. A remote (SSH) session is refused (400) before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it (Docker is fine, the workspace is bind-mounted at the same absolute path). ⚠️ A live session's workspace can still sit on a network mount that stopped answering, so the route probes it BOUNDED (`probePathKind(session.workingDir, { pastCap: true })`, the #516 rule) before the first disk touch and refuses an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED), the way session create does, so a mount already unreachable cannot freeze a libuv worker on every upload (a mount that dies after the probe is the same residual race create accepts). ⚠️ Dispatch mirrors the client: an `image/*` MIME takes the IMAGE path whatever the name says (a GIF named `animation.txt` stays a GIF), a document extension takes the document branch, and anything else takes the image path exactly as before, where the HEIC sniff still recovers a HEIC under a generic name; a foreign extension with no image MIME gets its `Unsupported file type` 400 only AFTER that sLine truncated
|
||||
|
||||
**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/<cap>/...` (`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)
|
||||
|
||||
|
||||
@@ -1124,7 +1124,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u
|
||||
|
||||
### Buffers, uploads, and terminal history
|
||||
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`.
|
||||
|
||||
**Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`.
|
||||
|
||||
|
||||
@@ -1319,6 +1319,8 @@
|
||||
[/^Worktree (\S+) and branch (\S+) removed$/, (_m, wt, branch) => `已移除工作树 ${wt} 和分支 ${branch}`],
|
||||
[/^Worktree (\S+) removed$/, (_m, wt) => `已移除工作树 ${wt}`],
|
||||
[/^Worktree (\S+) was kept: ([\s\S]+)$/, (_m, wt, why) => `工作树 ${wt} 已保留:${why}`],
|
||||
// A pick, drop or paste that mixed admitted files with refused ones (image-input.js).
|
||||
[/^(\d+) files? skipped: unsupported type$/, (_m, n) => `已跳过 ${n} 个不支持类型的文件`],
|
||||
// Group names are user text: they pass through untranslated.
|
||||
[/^Move to "(.+)"$/, (_m, group) => `移到“${group}”`],
|
||||
// Session names are user text too.
|
||||
|
||||
@@ -101,12 +101,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!this.activeSessionId) return;
|
||||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||
|
||||
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
|
||||
if (mediaFiles.length === 0) {
|
||||
this.showToast('Unsupported file type', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages(mediaFiles);
|
||||
const mediaFiles = this._admitPromptAttachments(Array.from(e.dataTransfer.files));
|
||||
if (mediaFiles.length > 0) this._uploadAndInsertImages(mediaFiles);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -122,11 +118,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
* Without the overlay (local echo off) there is nothing to buffer into, so the
|
||||
* text goes straight out. The trailing space keeps the next word off the path. */
|
||||
async _attachFromToolbar(files, sessionId) {
|
||||
const media = files.filter((f) => this._promptAttachKind(f));
|
||||
if (media.length === 0) {
|
||||
this.showToast('Unsupported file type', 'error');
|
||||
return;
|
||||
}
|
||||
const media = this._admitPromptAttachments(files);
|
||||
if (media.length === 0) return;
|
||||
if (!sessionId) return; // no captured session: refuse rather than guess at one
|
||||
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
|
||||
if (paths.length === 0) return;
|
||||
@@ -231,14 +224,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
pasteConsumed = true;
|
||||
|
||||
// Check for images, videos or documents in clipboard items
|
||||
var imageFiles = [];
|
||||
var pastedFiles = [];
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (items) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob && self._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
if (blob) pastedFiles.push(blob);
|
||||
}
|
||||
}
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
var imageFiles = self._admitPromptAttachments(pastedFiles, { textPasted: !!text });
|
||||
|
||||
// Clean up the trap
|
||||
setTimeout(function() {
|
||||
@@ -256,7 +251,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
// via raw sendInput() strips those markers and makes pasted input
|
||||
// indistinguishable from typed input, weakening the CLI's
|
||||
// prompt-injection defenses.
|
||||
var text = e.clipboardData ? e.clipboardData.getData('text/plain') : '';
|
||||
if (text && terminal) terminal.paste(text);
|
||||
}
|
||||
});
|
||||
@@ -290,6 +284,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
return null;
|
||||
},
|
||||
|
||||
/** The files of a pick, drop or paste that _promptAttachKind admits, telling the
|
||||
* user about any it refuses: all of them is "Unsupported file type", some of
|
||||
* them a count of what was skipped while the rest still upload. A clipboard
|
||||
* paste that also carries text passes `textPasted`: when none of its files is
|
||||
* admitted the text is pasted instead, so that case stays quiet. */
|
||||
_admitPromptAttachments(files, { textPasted = false } = {}) {
|
||||
const admitted = files.filter((f) => this._promptAttachKind(f));
|
||||
const skipped = files.length - admitted.length;
|
||||
if (skipped > 0 && admitted.length > 0) {
|
||||
this.showToast(`${skipped} file${skipped > 1 ? 's' : ''} skipped: unsupported type`, 'info');
|
||||
} else if (skipped > 0 && !textPasted) {
|
||||
this.showToast('Unsupported file type', 'error');
|
||||
}
|
||||
return admitted;
|
||||
},
|
||||
|
||||
// Max images accepted in one batch (paste / drop / mobile picker). Each is
|
||||
// uploaded as its own request, so 20 stays under the server's 30 uploads/min
|
||||
// rate limit while covering "select a bunch of photos at once".
|
||||
|
||||
@@ -1339,8 +1339,9 @@ const KeyboardAccessoryBar = {
|
||||
this._syncComposerDraftIndicator();
|
||||
close({ preserveDraft: false });
|
||||
};
|
||||
const handleImages = async (files) => {
|
||||
const images = Array.from(files || []).filter((file) => app._promptAttachKind(file));
|
||||
// Takes files already admitted by app._admitPromptAttachments (the picker and
|
||||
// the paste below), which also toasts whatever it refused.
|
||||
const handleImages = async (images) => {
|
||||
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||
saveDraft();
|
||||
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||
@@ -1381,7 +1382,6 @@ const KeyboardAccessoryBar = {
|
||||
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
|
||||
else this._composerUploads.delete(sessionId);
|
||||
syncUploadUi();
|
||||
if (overlay.isConnected) fileInput.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1389,9 +1389,11 @@ const KeyboardAccessoryBar = {
|
||||
textarea.addEventListener('paste', (event) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const images = Array.from(items)
|
||||
const files = Array.from(items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file) => file && app._promptAttachKind(file));
|
||||
.filter(Boolean);
|
||||
// Pasted text with no admitted file lands in the textarea natively: no toast.
|
||||
const images = app._admitPromptAttachments(files, { textPasted: !!event.clipboardData.getData('text/plain') });
|
||||
if (images.length > 0) {
|
||||
event.preventDefault();
|
||||
void handleImages(images);
|
||||
@@ -1404,7 +1406,12 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
});
|
||||
imageButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||
fileInput.addEventListener('change', () => {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
// Reset so picking the same refused file again still fires change (and its toast).
|
||||
fileInput.value = '';
|
||||
void handleImages(app._admitPromptAttachments(files));
|
||||
});
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
|
||||
// Hand the text BACK to the terminal prompt so both surfaces hold the same
|
||||
// prompt, the mirror of the adoption composePrompt() does on open. Only a
|
||||
@@ -1519,10 +1526,11 @@ const KeyboardAccessoryBar = {
|
||||
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
|
||||
};
|
||||
|
||||
// Filter to what the server admits, close the dialog, and hand off to the
|
||||
// shared upload+insert pipeline. Returns true if any file was handled.
|
||||
const handleImages = (files) => {
|
||||
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
|
||||
// Filter to what the server admits (app._admitPromptAttachments toasts what it
|
||||
// refuses), close the dialog, and hand off to the shared upload+insert
|
||||
// pipeline. Returns true if any file was handled.
|
||||
const handleImages = (files, options) => {
|
||||
const images = app._admitPromptAttachments(Array.from(files || []), options);
|
||||
if (images.length === 0) return false;
|
||||
close();
|
||||
// Upload to, and insert into, the shell this overlay was opened for.
|
||||
@@ -1532,21 +1540,24 @@ const KeyboardAccessoryBar = {
|
||||
|
||||
// File picker (camera / photo library / files) — the reliable mobile path.
|
||||
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => handleImages(fileInput.files));
|
||||
fileInput.addEventListener('change', () => {
|
||||
const files = Array.from(fileInput.files || []);
|
||||
// Reset so picking the same refused file again still fires change (and its toast).
|
||||
fileInput.value = '';
|
||||
handleImages(files);
|
||||
});
|
||||
|
||||
// Best-effort: capture images pasted straight into the textarea.
|
||||
textarea.addEventListener('paste', (e) => {
|
||||
const items = e.clipboardData && e.clipboardData.items;
|
||||
if (!items) return;
|
||||
const imageFiles = [];
|
||||
const files = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob && app._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
handleImages(imageFiles);
|
||||
if (blob) files.push(blob);
|
||||
}
|
||||
// Pasted text with no admitted file lands in the textarea natively: no toast.
|
||||
if (handleImages(files, { textPasted: !!e.clipboardData.getData('text/plain') })) e.preventDefault();
|
||||
});
|
||||
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', close);
|
||||
|
||||
@@ -346,12 +346,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!sessionId || files.length === 0) return;
|
||||
// The one classifier every pick and drop filter reads (image-input.js):
|
||||
// MIME first, then the name's extension, so documents and videos count too.
|
||||
const media = files.filter((f) => this._promptAttachKind?.(f));
|
||||
if (media.length === 0) {
|
||||
this.showToast?.('Unsupported file type', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages?.(media, { sessionId });
|
||||
// It toasts what it refuses.
|
||||
const media = this._admitPromptAttachments(files);
|
||||
if (media.length > 0) this._uploadAndInsertImages?.(media, { sessionId });
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ interface Harness {
|
||||
pastedText: string[];
|
||||
/** Image batches handed to _uploadAndInsertImages(), one entry per call. */
|
||||
uploadedBatches: Array<Array<{ type: string }>>;
|
||||
/** Messages handed to showToast(), one entry per call. */
|
||||
toasts: string[];
|
||||
/** How many trap divs are still attached to the fake body. */
|
||||
attachedTraps: () => number;
|
||||
runTimers: () => void;
|
||||
@@ -107,7 +109,8 @@ function loadPasteHarness(): Harness {
|
||||
app._uploadAndInsertImages = (files: Array<{ type: string }>) => {
|
||||
uploadedBatches.push(Array.from(files));
|
||||
};
|
||||
app.showToast = () => {};
|
||||
const toasts: string[] = [];
|
||||
app.showToast = (message: string) => toasts.push(message);
|
||||
|
||||
(app._handleImagePaste as () => void).call(app);
|
||||
|
||||
@@ -126,6 +129,7 @@ function loadPasteHarness(): Harness {
|
||||
},
|
||||
pastedText,
|
||||
uploadedBatches,
|
||||
toasts,
|
||||
attachedTraps: () => attached.size,
|
||||
runTimers: () => {
|
||||
const pending = timers.splice(0, timers.length);
|
||||
@@ -180,6 +184,36 @@ describe('Ctrl+V paste trap', () => {
|
||||
expect(h.pastedText).toEqual([]);
|
||||
});
|
||||
|
||||
it('a paste mixing an image with a refused file uploads the image and says what it skipped', () => {
|
||||
const h = loadPasteHarness();
|
||||
|
||||
h.firePaste({ text: 'shot.png', images: ['image/png', 'application/x-sh'] });
|
||||
|
||||
expect(h.uploadedBatches).toEqual([[{ type: 'image/png' }]]);
|
||||
expect(h.toasts).toEqual(['1 file skipped: unsupported type']);
|
||||
});
|
||||
|
||||
it('a paste whose only file is refused pastes its text instead, without a toast', () => {
|
||||
const h = loadPasteHarness();
|
||||
|
||||
// Copying from a document can carry both: the text is what the user gets.
|
||||
h.firePaste({ text: 'echo hi', images: ['application/x-sh'] });
|
||||
|
||||
expect(h.pastedText).toEqual(['echo hi']);
|
||||
expect(h.uploadedBatches).toEqual([]);
|
||||
expect(h.toasts).toEqual([]);
|
||||
});
|
||||
|
||||
it('a paste of a refused file and no text says so instead of doing nothing', () => {
|
||||
const h = loadPasteHarness();
|
||||
|
||||
h.firePaste({ images: ['application/x-sh'] });
|
||||
|
||||
expect(h.pastedText).toEqual([]);
|
||||
expect(h.uploadedBatches).toEqual([]);
|
||||
expect(h.toasts).toEqual(['Unsupported file type']);
|
||||
});
|
||||
|
||||
it('removes the trap and hands focus back after the paste it accepted', () => {
|
||||
const h = loadPasteHarness();
|
||||
|
||||
|
||||
@@ -13,6 +13,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const accessorySource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
|
||||
const appSource = readFileSync(resolve('src/web/public/app.js'), 'utf8');
|
||||
const imageInputSource = readFileSync(resolve('src/web/public/image-input.js'), 'utf8');
|
||||
// The real filter-and-toast helper both dialogs call (image-input.js), so these
|
||||
// tests pin the shipped wording rather than a copy of it.
|
||||
const { _admitPromptAttachments } = new Function(
|
||||
`class CodemanApp {}\n${imageInputSource}\nreturn CodemanApp.prototype;`
|
||||
)() as { _admitPromptAttachments: (files: unknown[], options?: { textPasted?: boolean }) => unknown[] };
|
||||
|
||||
type Timer = { callback: () => void; delay: number };
|
||||
|
||||
@@ -54,6 +60,7 @@ function loadComposer(sessionId = 'session-1') {
|
||||
? 'document'
|
||||
: null,
|
||||
showToast: vi.fn(),
|
||||
_admitPromptAttachments,
|
||||
};
|
||||
const schedule = (callback: () => void, delay = 0) => {
|
||||
timers.push({ callback, delay });
|
||||
@@ -566,7 +573,7 @@ describe('mobile prompt composer', () => {
|
||||
const pasteImage = () => {
|
||||
const item = { type: 'image/png', getAsFile: () => image };
|
||||
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'clipboardData', { value: { items: [item] } });
|
||||
Object.defineProperty(event, 'clipboardData', { value: { items: [item], getData: () => '' } });
|
||||
input.dispatchEvent(event);
|
||||
};
|
||||
|
||||
@@ -597,9 +604,37 @@ describe('mobile prompt composer', () => {
|
||||
|
||||
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video, pdf], { insert: false });
|
||||
expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info');
|
||||
await vi.waitFor(() => expect(textarea(document).value).toContain('/tmp/image-one.png'));
|
||||
});
|
||||
|
||||
it('says so when the Compose picker gets nothing it can upload', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
|
||||
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: [script] });
|
||||
|
||||
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
|
||||
});
|
||||
|
||||
it('leaves a text paste that also carries a refused file to the editor, without a toast', () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
bar.composePrompt();
|
||||
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
|
||||
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'clipboardData', {
|
||||
value: { items: [{ type: 'text/x-shellscript', getAsFile: () => script }], getData: () => 'echo hi' },
|
||||
});
|
||||
textarea(document).dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('puts an uploaded document path into the draft like a photo', async () => {
|
||||
const { app, bar, document } = loadComposer();
|
||||
app._uploadAndInsertImages.mockResolvedValueOnce(['/case/.codeman-uploads/paste-1-ab-notes.pdf']);
|
||||
@@ -628,6 +663,7 @@ describe('mobile prompt composer', () => {
|
||||
{ type: 'text/plain', getAsFile: () => null },
|
||||
{ type: 'video/mp4', getAsFile: () => video },
|
||||
],
|
||||
getData: () => '',
|
||||
},
|
||||
});
|
||||
input.dispatchEvent(event);
|
||||
@@ -724,6 +760,46 @@ describe('mobile shell Paste', () => {
|
||||
expect(document.querySelector('.paste-overlay')).toBeNull();
|
||||
});
|
||||
|
||||
it('uploads the admitted files of a mixed pick and says how many it skipped', () => {
|
||||
const { app, document } = openShellPaste();
|
||||
const fileInput = document.querySelector('.paste-overlay .paste-file-input') as HTMLInputElement;
|
||||
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
|
||||
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image, script] });
|
||||
|
||||
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([image], { sessionId: 'session-1' });
|
||||
expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info');
|
||||
});
|
||||
|
||||
it('keeps the dialog open and says so when the pick holds nothing it can upload', () => {
|
||||
const { app, document } = openShellPaste();
|
||||
const fileInput = document.querySelector('.paste-overlay .paste-file-input') as HTMLInputElement;
|
||||
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
|
||||
Object.defineProperty(fileInput, 'files', { configurable: true, value: [script] });
|
||||
|
||||
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
|
||||
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
|
||||
expect(document.querySelector('.paste-overlay')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('leaves a text paste that also carries a refused file to the textarea, without a toast', () => {
|
||||
const { app, document, input } = openShellPaste();
|
||||
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
|
||||
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'clipboardData', {
|
||||
value: { items: [{ type: 'text/x-shellscript', getAsFile: () => script }], getData: () => 'echo hi' },
|
||||
});
|
||||
input.dispatchEvent(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
expect(document.querySelector('.paste-overlay')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('sends nothing for an empty paste', () => {
|
||||
const { app, document, send, runTimers } = openShellPaste();
|
||||
|
||||
|
||||
@@ -299,7 +299,7 @@ const context = vm.createContext({
|
||||
});
|
||||
vm.runInContext(
|
||||
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` +
|
||||
`${read('tile-grid.js')}\n${read('mobile-overview.js')}\n` +
|
||||
`${read('tile-grid.js')}\n${read('mobile-overview.js')}\n${read('image-input.js')}\n` +
|
||||
'globalThis.__CodemanApp = CodemanApp;',
|
||||
context
|
||||
);
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
* phase): anywhere over it (a tile, an empty cell, a divider, its padding)
|
||||
* dragover and drop are cancelled, so the page never navigates, and a drop on
|
||||
* a tile uploads its files to THAT tile's session, through the same classifier
|
||||
* (_promptAttachKind: images, videos and documents) and the same "Unsupported
|
||||
* file type" toast as the single view. Tab and tile drags are the targets' own
|
||||
* (_promptAttachKind: images, videos and documents) and the same toasts as the
|
||||
* single view (_admitPromptAttachments: "Unsupported file type", or a count of
|
||||
* the files skipped). Tab and tile drags are the targets' own
|
||||
* (_acceptTabDrops, capture phase) and stay untouched.
|
||||
*
|
||||
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||
@@ -66,12 +67,13 @@ describe('a file dropped on a tile', () => {
|
||||
expect(app.activeSessionId).toBe('s-a');
|
||||
});
|
||||
|
||||
it('only the files the classifier admits are uploaded: a document and a video count, an unnamed text part does not', () => {
|
||||
it('only the files the classifier admits are uploaded, and the skipped one is counted: a document and a video count, an unnamed text part does not', () => {
|
||||
const app = gridApp();
|
||||
const pdf = { type: 'application/pdf', name: 'Q3 report.pdf' };
|
||||
const mov = { type: 'video/quicktime', name: 'IMG_0001.MOV' };
|
||||
section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG, pdf, mov]));
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, pdf, mov], { sessionId: 's-c' });
|
||||
expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info');
|
||||
});
|
||||
|
||||
it('a drop with nothing the classifier admits says so, as the single view does, and uploads nothing', () => {
|
||||
|
||||
@@ -246,6 +246,14 @@ async function exercise() {
|
||||
preventDefault: vi.fn(),
|
||||
});
|
||||
harvestAll(app, 'file drop');
|
||||
// A drop mixing an admitted image with a refused file: the admitted one uploads.
|
||||
app._uploadAndInsertImages = vi.fn();
|
||||
section.dispatch('drop', {
|
||||
target: section.children.find((el) => el.dataset.sessionId === 's-1'),
|
||||
dataTransfer: { types: ['Files'], files: [{ type: 'image/png' }, { type: 'application/pdf' }] },
|
||||
preventDefault: vi.fn(),
|
||||
});
|
||||
harvestAll(app, 'mixed file drop');
|
||||
|
||||
// The toasts of a full grid, by the cap and by the window.
|
||||
app._joinTileGridFromRun('s-7');
|
||||
@@ -349,6 +357,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
|
||||
'This group has no session to show as tiles',
|
||||
'No sessions to show as tiles',
|
||||
'Unsupported file type',
|
||||
'1 file skipped: unsupported type',
|
||||
'Split: unavailable while tiles are open',
|
||||
'Tiles: show several sessions side by side (right-click for how many)',
|
||||
'Tiles: back to a single session (right-click for how many tiles)',
|
||||
|
||||
@@ -20,7 +20,7 @@ function loadToolbar(sessionId: string | null = 'session-1', localEcho = true, k
|
||||
'<!DOCTYPE html><html><body><span class="btn-attach-mobile-wrap">' +
|
||||
'<button type="button" id="attachBtnMobile"></button>' +
|
||||
'<input type="file" id="attachFileInput" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple>' +
|
||||
'</span></body></html>'
|
||||
'</span><div id="terminalContainer"></div></body></html>'
|
||||
);
|
||||
const { window } = dom;
|
||||
// Stands in for the real bar: the production code reaches for an OPEN composer
|
||||
@@ -342,6 +342,49 @@ describe('phone toolbar Attach button', () => {
|
||||
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
|
||||
});
|
||||
|
||||
it('uploads the admitted files of a mixed selection and says how many it skipped', async () => {
|
||||
const { app, window, select } = loadToolbar();
|
||||
const photo = new window.File(['p'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const script = new window.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
|
||||
const pdf = new window.File(['%PDF-'], 'notes.pdf', { type: 'application/pdf' });
|
||||
select([photo, script, pdf]);
|
||||
await vi.waitFor(() => expect(app._localEchoOverlay.appendText).toHaveBeenCalledTimes(1));
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([photo, pdf], { insert: false, sessionId: 'session-1' });
|
||||
expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info');
|
||||
});
|
||||
|
||||
it('a file dropped on the terminal goes through the same filter and its toasts', () => {
|
||||
const { app, window } = loadToolbar();
|
||||
const drop = (files: File[]) => {
|
||||
const event = new window.Event('drop', { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, 'dataTransfer', { value: { files, types: ['Files'] } });
|
||||
window.document.getElementById('terminalContainer')!.dispatchEvent(event);
|
||||
};
|
||||
const photo = new window.File(['p'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const script = new window.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
|
||||
const page = new window.File(['<p>'], 'page.html', { type: 'text/html' });
|
||||
drop([photo, script, page]);
|
||||
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([photo]);
|
||||
expect(app.showToast).toHaveBeenCalledWith('2 files skipped: unsupported type', 'info');
|
||||
app.showToast.mockClear();
|
||||
app._uploadAndInsertImages.mockClear();
|
||||
drop([script]);
|
||||
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
|
||||
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
|
||||
});
|
||||
|
||||
it('_admitPromptAttachments: silent when it admits everything, and quiet for a text paste with nothing admitted', () => {
|
||||
const { app, window } = loadToolbar();
|
||||
const photo = new window.File(['p'], 'photo.jpg', { type: 'image/jpeg' });
|
||||
const script = new window.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
|
||||
expect(app._admitPromptAttachments([photo])).toEqual([photo]);
|
||||
expect(app._admitPromptAttachments([script], { textPasted: true })).toEqual([]);
|
||||
expect(app.showToast).not.toHaveBeenCalled();
|
||||
// Pasted text does not hide a partial refusal: the admitted file uploads, the text does not.
|
||||
expect(app._admitPromptAttachments([photo, script], { textPasted: true })).toEqual([photo]);
|
||||
expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info');
|
||||
});
|
||||
|
||||
it('classifies a pick by its name when the file manager supplied no MIME type', () => {
|
||||
const { app, window } = loadToolbar();
|
||||
const kind = (name: string, type: string) => app._promptAttachKind(new window.File(['x'], name, { type }));
|
||||
|
||||
Reference in new issue
Block a user