Skip to content

Commit 161ff42

Browse files
authored
carousel: improve image loading perf (microsoft#303662)
* carousel: improve image loading perf * resolve comments
1 parent 36ca95e commit 161ff42

4 files changed

Lines changed: 125 additions & 56 deletions

File tree

src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import { ResourceSet } from '../../../../base/common/map.js';
2929
import { INotificationService } from '../../../../platform/notification/common/notification.js';
3030
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
3131
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
32-
import { Limiter } from '../../../../base/common/async.js';
3332

3433
// --- Configuration ---
3534

@@ -181,26 +180,13 @@ async function collectImageFilesFromFolder(fileService: IFileService, folderUri:
181180
return imageUris;
182181
}
183182

184-
async function readImageFiles(fileService: IFileService, uris: URI[]): Promise<ICarouselImage[]> {
185-
const limiter = new Limiter<ICarouselImage | undefined>(10);
186-
const results = await Promise.all(
187-
uris.map(uri => limiter.queue(async () => {
188-
try {
189-
const content = await fileService.readFile(uri);
190-
const mimeType = getMediaMime(uri.path) ?? 'image/png';
191-
return {
192-
id: generateUuid(),
193-
name: basename(uri),
194-
mimeType,
195-
data: content.value,
196-
uri,
197-
};
198-
} catch {
199-
return undefined;
200-
}
201-
}))
202-
);
203-
return results.filter((r): r is ICarouselImage => r !== undefined);
183+
function createImageEntries(uris: URI[]): ICarouselImage[] {
184+
return uris.map(uri => ({
185+
id: generateUuid(),
186+
name: basename(uri),
187+
mimeType: getMediaMime(uri.path) ?? 'image/png',
188+
uri,
189+
}));
204190
}
205191

206192
class OpenImagesInCarouselFromExplorerAction extends Action2 {
@@ -298,11 +284,7 @@ class OpenImagesInCarouselFromExplorerAction extends Action2 {
298284
return;
299285
}
300286

301-
const images = await readImageFiles(fileService, imageUris);
302-
if (images.length === 0) {
303-
notificationService.error(localize('imageReadError', "Could not read the selected images."));
304-
return;
305-
}
287+
const images = createImageEntries(imageUris);
306288

307289
let startIndex = 0;
308290
if (startUri) {

src/vs/workbench/contrib/imageCarousel/browser/imageCarouselEditor.ts

Lines changed: 102 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { clamp } from '../../../../base/common/numbers.js';
1212
import { isMacintosh } from '../../../../base/common/platform.js';
1313
import { localize } from '../../../../nls.js';
1414
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
15+
import { IFileService } from '../../../../platform/files/common/files.js';
1516
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
1617
import { EditorPane } from '../../../browser/parts/editor/editorPane.js';
1718
import { IEditorOpenContext } from '../../../common/editor.js';
@@ -49,6 +50,7 @@ export class ImageCarouselEditor extends EditorPane {
4950
private _flatImages: IFlatImageEntry[] = [];
5051
private readonly _contentDisposables = this._register(new DisposableStore());
5152
private readonly _imageDisposables = this._register(new DisposableStore());
53+
private readonly _blobUrlCache = new Map<string, string>();
5254

5355
private _elements: {
5456
root: HTMLElement;
@@ -68,7 +70,8 @@ export class ImageCarouselEditor extends EditorPane {
6870
group: IEditorGroup,
6971
@ITelemetryService telemetryService: ITelemetryService,
7072
@IThemeService themeService: IThemeService,
71-
@IStorageService storageService: IStorageService
73+
@IStorageService storageService: IStorageService,
74+
@IFileService private readonly _fileService: IFileService
7275
) {
7376
super(ImageCarouselEditor.ID, group, telemetryService, themeService, storageService);
7477
}
@@ -95,6 +98,7 @@ export class ImageCarouselEditor extends EditorPane {
9598
override clearInput(): void {
9699
this._contentDisposables.clear();
97100
this._imageDisposables.clear();
101+
this._revokeCachedBlobUrls();
98102
this._zoomScale = 'fit';
99103
if (this._container) {
100104
clearNode(this._container);
@@ -114,6 +118,7 @@ export class ImageCarouselEditor extends EditorPane {
114118

115119
this._contentDisposables.clear();
116120
this._imageDisposables.clear();
121+
this._revokeCachedBlobUrls();
117122
clearNode(this._container);
118123

119124
if (this._flatImages.length === 0) {
@@ -263,11 +268,8 @@ export class ImageCarouselEditor extends EditorPane {
263268
btn.ariaLabel = localize('imageCarousel.thumbnailLabel', "Image {0} of {1}", currentFlatIndex + 1, this._flatImages.length);
264269

265270
const img = thumbnail.img as HTMLImageElement;
266-
const blob = new Blob([image.data.buffer.slice(0)], { type: image.mimeType });
267-
const url = URL.createObjectURL(blob);
268-
img.src = url;
271+
this._loadBlobUrl(image).then(url => { img.src = url; });
269272
img.alt = image.name;
270-
this._contentDisposables.add({ dispose: () => URL.revokeObjectURL(url) });
271273

272274
this._contentDisposables.add(addDisposableListener(btn, 'click', () => {
273275
this._currentIndex = currentFlatIndex;
@@ -290,20 +292,42 @@ export class ImageCarouselEditor extends EditorPane {
290292
* Update only the changing parts: main image src, caption, button states, thumbnail selection.
291293
* No DOM teardown/rebuild — eliminates the blank flash.
292294
*/
293-
private updateCurrentImage(): void {
295+
private async updateCurrentImage(): Promise<void> {
294296
if (!this._elements) {
295297
return;
296298
}
297299

298-
// Swap main image blob URL
299-
this._imageDisposables.clear();
300-
const entry = this._flatImages[this._currentIndex];
300+
// Capture the navigation index before starting async work so that
301+
// we can discard stale results if the user navigates while loading/decoding.
302+
const navigationIndex = this._currentIndex;
303+
304+
// Swap main image using cached/lazy-loaded blob URL.
305+
// Pre-decode via decode() before assigning to <img> so the browser
306+
// decodes on a worker thread, avoiding main-thread stalls during commit.
307+
const entry = this._flatImages[navigationIndex];
301308
const currentImage = entry.image;
302-
const blob = new Blob([currentImage.data.buffer.slice(0)], { type: currentImage.mimeType });
303-
const url = URL.createObjectURL(blob);
304-
this._elements.mainImage.src = url;
305-
this._elements.mainImage.alt = currentImage.name;
306-
this._imageDisposables.add({ dispose: () => URL.revokeObjectURL(url) });
309+
const url = await this._loadBlobUrl(currentImage);
310+
311+
// If the user navigated while loading the blob URL, discard this result.
312+
if (this._currentIndex !== navigationIndex) {
313+
return;
314+
}
315+
316+
const tmp = new Image();
317+
tmp.src = url;
318+
tmp.decode().then(() => {
319+
// Only apply if user hasn't navigated away during decode
320+
if (this._currentIndex === navigationIndex && this._elements) {
321+
this._elements.mainImage.src = url;
322+
this._elements.mainImage.alt = currentImage.name;
323+
}
324+
}, () => {
325+
// Decode failed (invalid image) — still show src for browser fallback
326+
if (this._currentIndex === navigationIndex && this._elements) {
327+
this._elements.mainImage.src = url;
328+
this._elements.mainImage.alt = currentImage.name;
329+
}
330+
});
307331

308332
// Reset zoom when switching images
309333
this._applyZoom('fit');
@@ -324,32 +348,80 @@ export class ImageCarouselEditor extends EditorPane {
324348
this._elements.prevBtn.disabled = this._currentIndex === 0;
325349
this._elements.nextBtn.disabled = this._currentIndex === this._flatImages.length - 1;
326350

327-
// Update thumbnail selection
351+
// Update thumbnail selection — only toggle active class and
352+
// call getBoundingClientRect on the active thumbnail to avoid
353+
// layout thrashing across all thumbnails on every navigation.
328354
for (let i = 0; i < this._thumbnailElements.length; i++) {
329355
const isActive = i === this._currentIndex;
330356
const thumbnail = this._thumbnailElements[i];
331357
thumbnail.classList.toggle('active', isActive);
332358
if (isActive) {
333359
thumbnail.setAttribute('aria-current', 'page');
334-
// Scroll only the thumbnail strip, not the entire editor
335-
const container = this._elements.sectionsContainer;
336-
const containerRect = container.getBoundingClientRect();
337-
const thumbRect = thumbnail.getBoundingClientRect();
338-
if (thumbRect.left < containerRect.left) {
339-
container.scrollLeft += thumbRect.left - containerRect.left;
340-
} else if (thumbRect.right > containerRect.right) {
341-
container.scrollLeft += thumbRect.right - containerRect.right;
342-
}
343360
} else {
344361
thumbnail.removeAttribute('aria-current');
345362
}
346363
}
347364

365+
// Scroll the active thumbnail into view without blocking the main thread.
366+
// Using scrollIntoView with 'nearest' avoids forced layout from
367+
// getBoundingClientRect + scrollLeft and is handled efficiently by
368+
// the browser's scroll machinery.
369+
const activeThumbnail = this._thumbnailElements[this._currentIndex];
370+
if (activeThumbnail) {
371+
activeThumbnail.scrollIntoView({ block: 'nearest', inline: 'nearest' });
372+
}
373+
348374
// Update editor title to reflect current section
349375
if (this.input instanceof ImageCarouselEditorInput) {
350376
const currentSection = this._sections[entry.sectionIndex];
351377
this.input.setName(currentSection.title || this.input.collection.title);
352378
}
379+
380+
// Preload adjacent images for smoother navigation
381+
this._preloadAdjacentImages();
382+
}
383+
384+
private async _loadBlobUrl(image: ICarouselImage): Promise<string> {
385+
const cached = this._blobUrlCache.get(image.id);
386+
if (cached) {
387+
return cached;
388+
}
389+
390+
let buffer: Uint8Array;
391+
if (image.data) {
392+
buffer = image.data.buffer;
393+
} else if (image.uri) {
394+
const content = await this._fileService.readFile(image.uri);
395+
buffer = content.value.buffer;
396+
} else {
397+
return '';
398+
}
399+
400+
const blob = new Blob([buffer as Uint8Array<ArrayBuffer>], { type: image.mimeType });
401+
const url = URL.createObjectURL(blob);
402+
this._blobUrlCache.set(image.id, url);
403+
return url;
404+
}
405+
406+
private _revokeCachedBlobUrls(): void {
407+
for (const url of this._blobUrlCache.values()) {
408+
URL.revokeObjectURL(url);
409+
}
410+
this._blobUrlCache.clear();
411+
}
412+
413+
private _preloadAdjacentImages(): void {
414+
for (const idx of [this._currentIndex - 1, this._currentIndex + 1]) {
415+
if (idx >= 0 && idx < this._flatImages.length) {
416+
this._loadBlobUrl(this._flatImages[idx].image).then(url => {
417+
// Pre-decode via decode() so the compositor doesn't block
418+
// the main thread decoding this image during commit.
419+
const img = new Image();
420+
img.src = url;
421+
img.decode().catch(() => { /* invalid image */ });
422+
});
423+
}
424+
}
353425
}
354426

355427
previous(): void {
@@ -431,9 +503,14 @@ export class ImageCarouselEditor extends EditorPane {
431503
img.classList.add('scale-to-fit');
432504
img.classList.remove('pixelated');
433505
img.style.zoom = '';
506+
// Remove zoomed/overflow before scrollTo to avoid an expensive
507+
// synchronous ScrollLayer that blocks the main thread.
508+
const wasZoomed = container.classList.contains('zoomed');
434509
container.classList.remove('zoomed');
435510
container.classList.remove('zoom-out');
436-
container.scrollTo(0, 0);
511+
if (wasZoomed) {
512+
container.scrollTo(0, 0);
513+
}
437514
} else {
438515
const scale = clamp(newScale, MIN_SCALE, MAX_SCALE);
439516
this._zoomScale = scale;

src/vs/workbench/contrib/imageCarousel/browser/imageCarouselTypes.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export interface ICarouselImage {
1010
readonly id: string;
1111
readonly name: string;
1212
readonly mimeType: string;
13-
readonly data: VSBuffer;
13+
/** In-memory image data. Omit when the image can be loaded lazily from `uri`. */
14+
readonly data?: VSBuffer;
1415
readonly uri?: URI;
1516
readonly source?: string;
1617
readonly caption?: string;

src/vs/workbench/contrib/imageCarousel/test/browser/imageCarousel.contribution.test.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,7 @@ suite('OpenImagesInCarouselFromExplorerAction', () => {
361361
assert.strictEqual(infoMessages.length, 0, 'Should not show info notification');
362362
});
363363

364-
test('all image reads failing shows error notification', async () => {
364+
test('images with URIs are passed lazily without reading file contents', async () => {
365365
const folderUri = URI.file('/workspace/broken');
366366

367367
const resolveMap = new Map<string, IFileStat>();
@@ -372,8 +372,13 @@ suite('OpenImagesInCarouselFromExplorerAction', () => {
372372
]
373373
));
374374

375-
// No file contents → all readFile calls will fail
375+
// No file contents — with lazy loading, no readFile should be called at action time
376+
let readFileCallCount = 0;
376377
stubFileService(resolveMap, new Map());
378+
instantiationService.stub(IFileService, 'readFile', async () => {
379+
readFileCallCount++;
380+
throw new Error('readFile should not be called');
381+
});
377382
stubExplorerService([]);
378383
stubEditorService();
379384
stubNotificationService();
@@ -384,7 +389,11 @@ suite('OpenImagesInCarouselFromExplorerAction', () => {
384389

385390
await instantiationService.invokeFunction(command.handler, folderUri);
386391

387-
assert.strictEqual(openedInputs.length, 0, 'Should not open carousel when all reads fail');
388-
assert.strictEqual(errorMessages.length, 1, 'Should show error notification for read failures');
392+
assert.strictEqual(readFileCallCount, 0, 'readFile should not be called during action');
393+
assert.strictEqual(openedInputs.length, 1, 'Should open carousel with lazy image entries');
394+
const images = openedInputs[0].input.collection.sections[0].images;
395+
assert.strictEqual(images.length, 2, 'Should include 2 lazy image entries');
396+
assert.strictEqual(images[0].data, undefined, 'Image data should not be loaded eagerly');
397+
assert.ok(images[0].uri, 'Image should have a URI for lazy loading');
389398
});
390399
});

0 commit comments

Comments
 (0)