From 2cfa2783adccb0c8939508dcb2c7ed8513247908 Mon Sep 17 00:00:00 2001 From: JD Date: Wed, 14 Oct 2026 21:58:05 -0400 Subject: [PATCH] fix(uploads): say how many files a mixed pick, drop or paste skipped A selection mixing a file the server takes with one it refuses dropped the refused one without a word, and the Compose and paste-dialog pickers never toasted even when every file was refused. Every pick, drop and paste filter now goes through one helper that counts what the classifier refused: a partial refusal toasts how many were skipped while the rest upload, a full one keeps the existing Unsupported file type toast, and a clipboard paste that falls back to its text stays quiet. --- src/web/public/i18n.js | 2 + src/web/public/image-input.js | 38 +++++++++----- src/web/public/keyboard-accessory.js | 45 ++++++++++------ src/web/public/tile-grid.js | 9 ++-- test/image-paste-trap.test.ts | 36 ++++++++++++- test/mobile-prompt-composer.test.ts | 78 +++++++++++++++++++++++++++- test/mocks/tile-grid-vm.ts | 2 +- test/tile-grid-file-drop.test.ts | 8 +-- test/tile-grid-i18n.test.ts | 9 ++++ test/toolbar-attach.test.ts | 45 +++++++++++++++- 10 files changed, 228 insertions(+), 44 deletions(-) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 42657a23..3a9d4b75 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -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. diff --git a/src/web/public/image-input.js b/src/web/public/image-input.js index a0b27ba2..a0ec33c3 100644 --- a/src/web/public/image-input.js +++ b/src/web/public/image-input.js @@ -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". diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index e584e8f1..d76aa9c1 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -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); diff --git a/src/web/public/tile-grid.js b/src/web/public/tile-grid.js index d95c6b27..018dd2f3 100644 --- a/src/web/public/tile-grid.js +++ b/src/web/public/tile-grid.js @@ -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 }); }); }, diff --git a/test/image-paste-trap.test.ts b/test/image-paste-trap.test.ts index c8bcca68..93370ae3 100644 --- a/test/image-paste-trap.test.ts +++ b/test/image-paste-trap.test.ts @@ -40,6 +40,8 @@ interface Harness { pastedText: string[]; /** Image batches handed to _uploadAndInsertImages(), one entry per call. */ uploadedBatches: Array>; + /** 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(); diff --git a/test/mobile-prompt-composer.test.ts b/test/mobile-prompt-composer.test.ts index 3d62b667..77576db1 100644 --- a/test/mobile-prompt-composer.test.ts +++ b/test/mobile-prompt-composer.test.ts @@ -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(); diff --git a/test/mocks/tile-grid-vm.ts b/test/mocks/tile-grid-vm.ts index 52c82cd6..bf629078 100644 --- a/test/mocks/tile-grid-vm.ts +++ b/test/mocks/tile-grid-vm.ts @@ -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 ); diff --git a/test/tile-grid-file-drop.test.ts b/test/tile-grid-file-drop.test.ts index c51b9220..21872e91 100644 --- a/test/tile-grid-file-drop.test.ts +++ b/test/tile-grid-file-drop.test.ts @@ -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', () => { diff --git a/test/tile-grid-i18n.test.ts b/test/tile-grid-i18n.test.ts index 9c8e5dd5..597e8e74 100644 --- a/test/tile-grid-i18n.test.ts +++ b/test/tile-grid-i18n.test.ts @@ -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)', diff --git a/test/toolbar-attach.test.ts b/test/toolbar-attach.test.ts index 36318c9f..91c9a652 100644 --- a/test/toolbar-attach.test.ts +++ b/test/toolbar-attach.test.ts @@ -20,7 +20,7 @@ function loadToolbar(sessionId: string | null = 'session-1', localEcho = true, k '' + '' + '' + - '' + '
' ); 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(['

'], '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 }));