mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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.
This commit is contained in:
1 parent
ce809eac41
commit
2cfa2783ad
10 files changed
+228
-44
No files matched your search
@@ -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