Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/web/public/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 24 additions & 14 deletions src/web/public/image-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
},

Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand All @@ -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);
}
});
Expand Down Expand Up @@ -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".
Expand Down
45 changes: 28 additions & 17 deletions src/web/public/keyboard-accessory.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1381,17 +1382,18 @@ const KeyboardAccessoryBar = {
if (remaining > 0) this._composerUploads.set(sessionId, remaining);
else this._composerUploads.delete(sessionId);
syncUploadUi();
if (overlay.isConnected) fileInput.value = '';
}
};

textarea.addEventListener('input', saveDraft);
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);
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand Down
9 changes: 3 additions & 6 deletions src/web/public/tile-grid.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
},

Expand Down
36 changes: 35 additions & 1 deletion test/image-paste-trap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand All @@ -126,6 +129,7 @@ function loadPasteHarness(): Harness {
},
pastedText,
uploadedBatches,
toasts,
attachedTraps: () => attached.size,
runTimers: () => {
const pending = timers.splice(0, timers.length);
Expand Down Expand Up @@ -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();

Expand Down
78 changes: 77 additions & 1 deletion test/mobile-prompt-composer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -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']);
Expand Down Expand Up @@ -628,6 +663,7 @@ describe('mobile prompt composer', () => {
{ type: 'text/plain', getAsFile: () => null },
{ type: 'video/mp4', getAsFile: () => video },
],
getData: () => '',
},
});
input.dispatchEvent(event);
Expand Down Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion test/mocks/tile-grid-vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand Down
Loading
Loading