Skip to content

Commit 4ffc120

Browse files
digitaraldCopilot
andcommitted
imageCarousel: address provenance, export format and reveal feedback
Exclude pasted cache references from source actions. Reuse image signature detection for resized attachment MIME and correct the suggested save extension. Suspend Explorer auto-reveal while closing the modal and selecting its source, restoring the previous setting even on failure. Add regression coverage for actual GIF resizing, encoded formats, pasted references, export filenames and reveal failure paths. Refs #334682 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 93ea7d5 commit 4ffc120

8 files changed

Lines changed: 172 additions & 34 deletions

File tree

‎src/vs/base/common/image.ts‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,24 +24,36 @@ export function readImageDimensions(buffer: VSBuffer): IImageDimensions | undefi
2424
if (bytes.length < 12) {
2525
return undefined;
2626
}
27+
switch (getImageMimeType(buffer)) {
28+
case 'image/jpeg': return readJpegDimensions(bytes);
29+
case 'image/png': return readPngDimensions(bytes);
30+
case 'image/gif': return readGifDimensions(bytes);
31+
case 'image/webp': return readWebPDimensions(bytes);
32+
default: return undefined;
33+
}
34+
}
35+
36+
/** Detect JPEG, PNG, GIF or WebP from the encoded buffer's signature. */
37+
export function getImageMimeType(buffer: VSBuffer): string | undefined {
38+
const bytes = buffer.buffer;
2739
// JPEG: FF D8
2840
if (bytes[0] === 0xFF && bytes[1] === 0xD8) {
29-
return readJpegDimensions(bytes);
41+
return 'image/jpeg';
3042
}
3143
// PNG: 89 50 4E 47 0D 0A 1A 0A
3244
if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47 &&
3345
bytes[4] === 0x0D && bytes[5] === 0x0A && bytes[6] === 0x1A && bytes[7] === 0x0A) {
34-
return readPngDimensions(bytes);
46+
return 'image/png';
3547
}
3648
// GIF: "GIF87a" or "GIF89a"
3749
if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 &&
3850
(bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) {
39-
return readGifDimensions(bytes);
51+
return 'image/gif';
4052
}
4153
// WebP: "RIFF" <size> "WEBP"
4254
if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
4355
bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
44-
return readWebPDimensions(bytes);
56+
return 'image/webp';
4557
}
4658
return undefined;
4759
}

‎src/vs/base/test/common/image.test.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import assert from 'assert';
77
import { VSBuffer } from '../../common/buffer.js';
8-
import { readImageDimensions } from '../../common/image.js';
8+
import { getImageMimeType, readImageDimensions } from '../../common/image.js';
99
import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js';
1010

1111
function buf(...bytes: number[]): VSBuffer {
@@ -99,6 +99,24 @@ function makeWebPVp8x(width: number, height: number): VSBuffer {
9999
);
100100
}
101101

102+
suite('getImageMimeType', () => {
103+
ensureNoDisposablesAreLeakedInTestSuite();
104+
105+
test('detects encoded formats and rejects incomplete or unrecognized signatures', () => {
106+
const fixtures = [
107+
makeJpeg(640, 480), makePng(640, 480), makeGif(640, 480),
108+
makeWebPVp8(640, 480), makeWebPVp8l(640, 480), makeWebPVp8x(640, 480),
109+
VSBuffer.alloc(0), buf(0xFF), makePng(1, 1).slice(0, 7),
110+
makeGif(1, 1).slice(0, 5), makeWebPVp8(1, 1).slice(0, 11),
111+
VSBuffer.fromString('<svg xmlns="http://www.w3.org/2000/svg"/>'),
112+
];
113+
assert.deepStrictEqual(fixtures.map(getImageMimeType), [
114+
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/webp', 'image/webp',
115+
undefined, undefined, undefined, undefined, undefined, undefined,
116+
]);
117+
});
118+
});
119+
102120
suite('readImageDimensions', () => {
103121
ensureNoDisposablesAreLeakedInTestSuite();
104122

‎src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { renderAsPlaintext } from '../../../../base/browser/markdownRenderer.js';
77
import { IMarkdownString } from '../../../../base/common/htmlContent.js';
8+
import { getImageMimeType } from '../../../../base/common/image.js';
89
import { stripIcons } from '../../../../base/common/iconLabels.js';
910
import { getMediaMime } from '../../../../base/common/mime.js';
1011
import { isEqual } from '../../../../base/common/resources.js';
@@ -277,7 +278,7 @@ export function buildSingleImageArgs(resource: URI, data: Uint8Array): ICarousel
277278
} catch {
278279
// keep raw segment if it isn't valid percent-encoding
279280
}
280-
const mimeType = getMediaMime(resource.path) ?? getMediaMime(name) ?? 'image/png';
281+
const mimeType = getImageMimeType(VSBuffer.wrap(data)) ?? getMediaMime(resource.path) ?? getMediaMime(name) ?? 'image/png';
281282
return { name, mimeType, data, title: name, sourceUri: getChatImageSourceUri(resource) };
282283
}
283284

‎src/vs/workbench/contrib/chat/common/chatImageExtraction.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js';
77
import { IMarkdownString } from '../../../../base/common/htmlContent.js';
8+
import { getImageMimeType } from '../../../../base/common/image.js';
89
import { getExtensionForMimeType, getMediaMime } from '../../../../base/common/mime.js';
910
import { Schemas } from '../../../../base/common/network.js';
1011
import { URI } from '../../../../base/common/uri.js';
@@ -245,16 +246,17 @@ export function extractImagesFromChatVariables(
245246
if (!buffer) {
246247
continue;
247248
}
248-
const mimeType = variable.mimeType ?? getMediaMime(variable.name) ?? 'image/png';
249+
const data = VSBuffer.wrap(buffer);
250+
const mimeType = getImageMimeType(data) ?? variable.mimeType ?? getMediaMime(variable.name) ?? 'image/png';
249251
const uri = variable.references?.[0]?.reference;
250252
const imageUri = URI.isUri(uri) ? uri : URI.from({ scheme: 'data', path: `${variable.id}/${encodeURIComponent(variable.name)}` });
251253
images.push({
252254
id: imageUri.toString(),
253255
uri: imageUri,
254-
sourceUri: getChatImageSourceUri(imageUri),
256+
sourceUri: variable.isPasted ? undefined : getChatImageSourceUri(imageUri),
255257
name: variable.name,
256258
mimeType,
257-
data: VSBuffer.wrap(buffer),
259+
data,
258260
source: localize('chatImageExtraction.userAttachment', "Attachment"),
259261
caption: undefined,
260262
});

‎src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
*--------------------------------------------------------------------------------------------*/
55

66
import assert from 'assert';
7-
import { VSBuffer } from '../../../../../base/common/buffer.js';
7+
import { decodeBase64, VSBuffer } from '../../../../../base/common/buffer.js';
88
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
99
import { URI } from '../../../../../base/common/uri.js';
1010
import { buildCollectionArgs, buildSingleImageArgs, collectCarouselSections, findClickedImageIndex, ICarouselSection } from '../../browser/chatImageCarouselService.js';
11+
import { resizeImage } from '../../browser/chatImageUtils.js';
1112
import { IChatToolInvocationSerialized } from '../../common/chatService/chatService.js';
1213
import { ChatResponseResource } from '../../common/model/chatModel.js';
1314
import { IImageVariableEntry } from '../../common/attachments/chatVariableEntries.js';
@@ -177,6 +178,14 @@ suite('ChatImageCarouselService helpers', () => {
177178
});
178179

179180
suite('buildSingleImageArgs', () => {
181+
test('uses encoded MIME rather than the resource extension', () => {
182+
const data = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
183+
const args = buildSingleImageArgs(URI.file('/photos/image.gif'), data);
184+
assert.deepStrictEqual({ name: args.name, mimeType: args.mimeType, data: args.data }, {
185+
name: 'image.gif', mimeType: 'image/png', data,
186+
});
187+
});
188+
180189

181190
test('keeps real source URIs but excludes synthetic and generated resources', () => {
182191
const uris = [
@@ -226,6 +235,22 @@ suite('ChatImageCarouselService helpers', () => {
226235
});
227236

228237
suite('collectCarouselSections', () => {
238+
test('preserves resized GIF bytes with PNG MIME and excludes pasted cache sources', async () => {
239+
const gif = decodeBase64('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
240+
const data = await resizeImage(gif.buffer, 'image/gif');
241+
const sections = await collectCarouselSections([], async () => { throw new Error('Unexpected file read'); }, {
242+
text: '',
243+
attachments: [makeImageVariableEntry({
244+
name: 'image.gif', mimeType: 'image/gif', value: data, isPasted: true,
245+
references: [{ kind: 'reference', reference: URI.file('/cache/image.gif') }],
246+
})],
247+
});
248+
const image = sections[0].images[0];
249+
assert.deepStrictEqual({ mimeType: image.mimeType, sourceUri: image.sourceUri, data: [...image.data] }, {
250+
mimeType: 'image/png', sourceUri: undefined, data: [...data],
251+
});
252+
});
253+
229254

230255
test('preserves image identity and provenance in response, pending request and current input sections', async () => {
231256
const uris = [

‎src/vs/workbench/contrib/chat/test/common/chatImageExtraction.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,30 @@ suite('extractImagesFromChatRequest', () => {
511511
assert.deepStrictEqual([...result[0].data.buffer], [1, 2, 3]);
512512
});
513513

514+
test('pasted attachments retain cache identities without exposing them as sources', () => {
515+
const uri = URI.file('/cache/pasted-image.png');
516+
const request = makeRequest([makeImageVariableEntry({
517+
value: new Uint8Array([1, 2, 3]),
518+
isPasted: true,
519+
references: [{ kind: 'reference', reference: uri }],
520+
})]);
521+
assert.deepStrictEqual(extractImagesFromChatRequest(request).map(image => ({
522+
id: image.id, uri: image.uri.toString(), sourceUri: image.sourceUri,
523+
})), [{ id: uri.toString(), uri: uri.toString(), sourceUri: undefined }]);
524+
});
525+
526+
test('uses encoded attachment MIME after resizing without changing bytes or identity', () => {
527+
const data = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
528+
const request = makeRequest(['gif', 'webp', 'jpeg'].map(extension => makeImageVariableEntry({
529+
id: extension, name: `image.${extension}`, mimeType: `image/${extension}`, value: data,
530+
})));
531+
assert.deepStrictEqual(extractImagesFromChatRequest(request).map(image => ({
532+
name: image.name, mimeType: image.mimeType, data: [...image.data.buffer],
533+
})), ['gif', 'webp', 'jpeg'].map(extension => ({
534+
name: `image.${extension}`, mimeType: 'image/png', data: [...data],
535+
})));
536+
});
537+
514538
test('extracts image attachment from ArrayBuffer', () => {
515539
const request = makeRequest([
516540
makeImageVariableEntry({ value: new Uint8Array([4, 5, 6]).buffer }),

‎src/vs/workbench/contrib/imageCarousel/browser/imageCarouselActions.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import { triggerDownload } from '../../../../base/browser/dom.js';
77
import { status } from '../../../../base/browser/ui/aria/aria.js';
88
import { Codicon } from '../../../../base/common/codicons.js';
9-
import { getExtensionForMimeType } from '../../../../base/common/mime.js';
9+
import { getExtensionForMimeType, getMediaMime } from '../../../../base/common/mime.js';
1010
import { isWeb } from '../../../../base/common/platform.js';
1111
import { basename, extname } from '../../../../base/common/path.js';
1212
import { joinPath } from '../../../../base/common/resources.js';
@@ -25,6 +25,7 @@ import { resolveCommandsContext } from '../../../browser/parts/editor/editorComm
2525
import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js';
2626
import { IEditorService } from '../../../services/editor/common/editorService.js';
2727
import { IExplorerService } from '../../files/browser/files.js';
28+
import { ExplorerView } from '../../files/browser/views/explorerView.js';
2829
import { VIEW_ID } from '../../files/common/files.js';
2930
import { IViewsService } from '../../../services/views/common/viewsService.js';
3031
import { ImageCarouselEditor } from './imageCarouselEditor.js';
@@ -99,7 +100,7 @@ registerAction2(class extends ImageCarouselAction {
99100
const dialogs = accessor.get(IFileDialogService);
100101
const extension = getExtensionForMimeType(image.mimeType) ?? '';
101102
const name = basename(image.name.replace(/\\/g, '/')) || `image${extension}`;
102-
const filename = extname(name) ? name : `${name}${extension}`;
103+
const filename = extension && getMediaMime(name) !== image.mimeType ? `${name.slice(0, name.length - extname(name).length)}${extension}` : name;
103104
const data = await editor.getCurrentImageData();
104105
if (isWeb) {
105106
triggerDownload(data.buffer, filename);
@@ -155,12 +156,19 @@ registerAction2(class extends ImageCarouselAction {
155156
}
156157
const input = editor.input!;
157158
const explorer = accessor.get(IExplorerService);
158-
const view = await accessor.get(IViewsService).openView(explorer.getViewId() ?? VIEW_ID, false);
159+
const view = await accessor.get(IViewsService).openView<ExplorerView>(explorer.getViewId() ?? VIEW_ID, false);
159160
if (!view) {
160161
throw new Error(localize('imageCarousel.explorerUnavailable', "The Explorer view is not available."));
161162
}
162-
await editor.group.closeEditor(input);
163-
await explorer.select(resource, 'force');
164-
view.focus();
163+
const autoReveal = view.autoReveal;
164+
view.autoReveal = false;
165+
try {
166+
await editor.group.closeEditor(input);
167+
view.setExpanded(true);
168+
await explorer.select(resource, 'force');
169+
view.focus();
170+
} finally {
171+
view.autoReveal = autoReveal;
172+
}
165173
}
166174
});

‎src/vs/workbench/contrib/imageCarousel/test/browser/imageCarouselActions.test.ts‎

Lines changed: 66 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import assert from 'assert';
77
import sinon from 'sinon';
8-
import { VSBuffer } from '../../../../../base/common/buffer.js';
8+
import { decodeBase64, VSBuffer } from '../../../../../base/common/buffer.js';
99
import { CancellationToken } from '../../../../../base/common/cancellation.js';
1010
import { URI } from '../../../../../base/common/uri.js';
1111
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
@@ -26,7 +26,7 @@ import { ImageCarouselEditor } from '../../browser/imageCarouselEditor.js';
2626
import { ImageCarouselEditorInput } from '../../browser/imageCarouselEditorInput.js';
2727
import { ICarouselImage, ImageCarouselContextKeys, ImageCarouselContextMenu } from '../../browser/imageCarouselTypes.js';
2828
import { isWeb, OS } from '../../../../../base/common/platform.js';
29-
import { IExplorerService } from '../../../files/browser/files.js';
29+
import { IExplorerService, IExplorerView } from '../../../files/browser/files.js';
3030
import { IViewsService } from '../../../../services/views/common/viewsService.js';
3131
import { KeybindingsRegistry } from '../../../../../platform/keybinding/common/keybindingsRegistry.js';
3232
import { KeybindingResolver, ResultKind } from '../../../../../platform/keybinding/common/keybindingResolver.js';
@@ -178,23 +178,51 @@ suite('ImageCarouselActions', () => {
178178
});
179179
});
180180

181-
test('reveal uses the registered Explorer view in the Agents window', async () => {
182-
const sourceUri = URI.file('/images/image.svg');
183-
await load([{ id: 'image', name: 'Image', mimeType: 'image/svg+xml', data: svg, sourceUri }]);
184-
const calls: string[] = [];
185-
instantiation.stub(IExplorerService, {
186-
getViewId: () => 'sessions.files.explorer',
187-
select: async (resource: URI) => { calls.push(resource.toString()); },
181+
for (const failureAt of [undefined, 'close', 'select']) {
182+
test(`reveal suppresses auto-reveal and restores it after ${failureAt ?? 'success'}`, async () => {
183+
const sourceUri = URI.file('/images/image.svg');
184+
await load([{ id: 'image', name: 'Image', mimeType: 'image/svg+xml', data: svg, sourceUri }]);
185+
const calls: string[] = [];
186+
const failure = new Error('Reveal failed');
187+
const view = {
188+
autoReveal: 'focusNoScroll' as IExplorerView['autoReveal'],
189+
setExpanded: (expanded: boolean) => { calls.push(`expanded:${expanded}`); },
190+
focus: () => { calls.push(`focus:${view.autoReveal}`); },
191+
};
192+
instantiation.stub(IExplorerService, {
193+
getViewId: () => 'sessions.files.explorer',
194+
select: async (resource: URI, reveal: string) => {
195+
calls.push(`select:${view.autoReveal}:${reveal}:${resource.toString()}`);
196+
if (failureAt === 'select') {
197+
throw failure;
198+
}
199+
},
200+
});
201+
instantiation.stub(IViewsService, {});
202+
instantiation.stub(IViewsService, 'openView', async (id: string) => {
203+
calls.push(id);
204+
return view;
205+
});
206+
sinon.stub(group, 'closeEditor').callsFake(async () => {
207+
calls.push(`close:${view.autoReveal}`);
208+
if (failureAt === 'close') {
209+
throw failure;
210+
}
211+
return true;
212+
});
213+
await run('imageCarousel.revealSource');
214+
const expected = ['sessions.files.explorer', 'close:false'];
215+
if (failureAt !== 'close') {
216+
expected.push('expanded:true', `select:false:force:${sourceUri.toString()}`);
217+
if (!failureAt) {
218+
expected.push('focus:false');
219+
}
220+
}
221+
assert.deepStrictEqual({ calls, errors, autoReveal: view.autoReveal }, {
222+
calls: expected, errors: failureAt ? [failure] : [], autoReveal: 'focusNoScroll',
223+
});
188224
});
189-
instantiation.stub(IViewsService, {});
190-
instantiation.stub(IViewsService, 'openView', async (id: string) => {
191-
calls.push(id);
192-
return { focus: () => calls.push('focus') };
193-
});
194-
sinon.stub(group, 'closeEditor').callsFake(async () => { calls.push('close'); return true; });
195-
await run('imageCarousel.revealSource');
196-
assert.deepStrictEqual({ calls, errors }, { calls: ['sessions.files.explorer', 'close', sourceUri.toString(), 'focus'], errors: [] });
197-
});
225+
}
198226

199227
test('save preserves encoding, supplies an extension and respects cancellation', async function () {
200228
if (isWeb) {
@@ -211,11 +239,31 @@ suite('ImageCarouselActions', () => {
211239
writes.push(data.toString());
212240
return { resource: target };
213241
});
242+
214243
await run('imageCarousel.saveMediaAs');
215244
cancel = true;
216245
await run('imageCarousel.saveMediaAs');
217246
assert.deepStrictEqual({ names: options.map(option => option.defaultUri?.path), writes, errors }, {
218247
names: ['/saved/Image.svg', '/saved/Image.svg'], writes: [svg.toString()], errors: [],
219248
});
220249
});
250+
251+
test('save corrects an extension that disagrees with the encoded MIME', async function () {
252+
if (isWeb) {
253+
this.skip();
254+
}
255+
const png = decodeBase64($<HTMLCanvasElement>('canvas').toDataURL('image/png').split(',')[1]);
256+
await load([{ id: 'image', name: 'image.gif', mimeType: 'image/png', data: png }]);
257+
let filename: string | undefined;
258+
instantiation.stub(IFileDialogService, 'defaultFilePath', async () => URI.file('/saved'));
259+
instantiation.stub(IFileDialogService, 'showSaveDialog', async (options: ISaveDialogOptions) => {
260+
filename = options.defaultUri?.path;
261+
return URI.file('/saved/image.png');
262+
});
263+
const write = instantiation.stub(IFileService, 'writeFile', async (resource: URI) => ({ resource }));
264+
await run('imageCarousel.saveMediaAs');
265+
assert.deepStrictEqual({ filename, data: write.firstCall.args[1], errors }, {
266+
filename: '/saved/image.png', data: png, errors: [],
267+
});
268+
});
221269
});

0 commit comments

Comments
 (0)