Path: blob/main/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import './media/imageCarousel.css';6import { localize, localize2 } from '../../../../nls.js';7import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';8import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';9import { Registry } from '../../../../platform/registry/common/platform.js';10import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js';11import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../common/editor.js';12import { IEditorService } from '../../../services/editor/common/editorService.js';13import { VSBuffer } from '../../../../base/common/buffer.js';14import { generateUuid } from '../../../../base/common/uuid.js';15import { ImageCarouselEditor } from './imageCarouselEditor.js';16import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js';17import { ICarouselImage, IImageCarouselCollection } from './imageCarouselTypes.js';18import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';19import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';20import { ExplorerFolderContext } from '../../files/common/files.js';21import { IExplorerService } from '../../files/browser/files.js';22import { ResourceContextKey } from '../../../common/contextkeys.js';23import { IFileService } from '../../../../platform/files/common/files.js';24import { getMediaMime } from '../../../../base/common/mime.js';25import { URI } from '../../../../base/common/uri.js';26import { basename, dirname, extname } from '../../../../base/common/resources.js';27import { ResourceSet } from '../../../../base/common/map.js';28import { INotificationService } from '../../../../platform/notification/common/notification.js';29import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';30import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';3132// --- Configuration ---3334Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({35id: 'imageCarousel',36title: localize('imageCarouselConfigurationTitle', "Images Preview"),37type: 'object',38properties: {39'imageCarousel.explorerContextMenu.enabled': {40type: 'boolean',41default: true,42markdownDescription: localize('imageCarousel.explorerContextMenu.enabled', "Controls whether the **Open in Images Preview** option appears in the Explorer context menu."),43tags: ['experimental'],44},45'imageCarousel.chat.enabled': {46type: 'boolean',47default: true,48description: localize('imageCarousel.chat.enabled', "Controls whether clicking an image attachment in chat opens the Images Preview viewer."),49tags: ['experimental'],50},51}52});5354// --- Editor Pane Registration ---5556Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(57EditorPaneDescriptor.create(58ImageCarouselEditor,59ImageCarouselEditor.ID,60localize('imageCarouselEditor', "Images Preview")61),62[63new SyncDescriptor(ImageCarouselEditorInput)64]65);6667// --- Serializer ---6869class ImageCarouselEditorInputSerializer implements IEditorSerializer {70canSerialize(): boolean {71return false;72}7374serialize(): string | undefined {75return undefined;76}7778deserialize(): ImageCarouselEditorInput | undefined {79return undefined;80}81}8283Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory)84.registerEditorSerializer(ImageCarouselEditorInput.ID, ImageCarouselEditorInputSerializer);8586// --- Args Types ---8788interface IOpenCarouselCollectionArgs {89readonly collection: IImageCarouselCollection;90readonly startIndex: number;91}9293interface IOpenCarouselSingleImageArgs {94readonly name: string;95readonly mimeType: string;96readonly data: Uint8Array;97readonly title?: string;98}99100function isCollectionArgs(args: unknown): args is IOpenCarouselCollectionArgs {101return typeof args === 'object' && args !== null102&& typeof (args as IOpenCarouselCollectionArgs).collection === 'object'103&& typeof (args as IOpenCarouselCollectionArgs).startIndex === 'number';104}105106function isSingleImageArgs(args: unknown): args is IOpenCarouselSingleImageArgs {107return typeof args === 'object' && args !== null108&& typeof (args as IOpenCarouselSingleImageArgs).name === 'string'109&& typeof (args as IOpenCarouselSingleImageArgs).mimeType === 'string'110&& (args as IOpenCarouselSingleImageArgs).data instanceof Uint8Array;111}112113// --- Actions ---114115class OpenImageInCarouselAction extends Action2 {116constructor() {117super({118id: 'workbench.action.chat.openImageInCarousel',119title: localize2('openImageInCarousel', "Open in Images Preview"),120f1: false121});122}123124async run(accessor: ServicesAccessor, args?: unknown): Promise<void> {125const editorService = accessor.get(IEditorService);126127let collection: IImageCarouselCollection;128let startIndex: number;129130if (isCollectionArgs(args)) {131collection = args.collection;132startIndex = args.startIndex;133} else if (isSingleImageArgs(args)) {134collection = {135id: generateUuid(),136title: args.title ?? localize('imageCarousel.title', "Images Preview"),137sections: [{138title: '',139images: [{140id: generateUuid(),141name: args.name,142mimeType: args.mimeType,143data: VSBuffer.wrap(args.data),144}],145}],146};147startIndex = 0;148} else {149return;150}151152const input = new ImageCarouselEditorInput(collection, startIndex);153await editorService.openEditor(input, { pinned: true });154}155}156157registerAction2(OpenImageInCarouselAction);158159// --- Explorer Context Menu Integration ---160161/** Supported media (image + video) extensions for the carousel explorer context menu. */162const MEDIA_EXTENSION_REGEX = /^\.(png|jpg|jpeg|jpe|gif|webp|svg|bmp|ico|mp4|webm|mov)$/i;163164function isMediaResource(uri: URI): boolean {165return MEDIA_EXTENSION_REGEX.test(extname(uri));166}167168async function collectImageFilesFromFolder(fileService: IFileService, folderUri: URI): Promise<URI[]> {169const stat = await fileService.resolve(folderUri);170const imageUris: URI[] = [];171if (stat.children) {172for (const child of stat.children) {173if (child.isFile && isMediaResource(child.resource)) {174imageUris.push(child.resource);175}176}177}178imageUris.sort((a, b) => basename(a).localeCompare(basename(b)));179return imageUris;180}181182function createImageEntries(uris: URI[]): ICarouselImage[] {183return uris.map(uri => ({184id: generateUuid(),185name: basename(uri),186mimeType: getMediaMime(uri.path) ?? 'image/png',187uri,188}));189}190191class OpenImagesInCarouselFromExplorerAction extends Action2 {192constructor() {193super({194id: 'workbench.action.openImagesInCarousel',195title: localize2('openImagesInCarousel', "Open in Images Preview"),196f1: false,197menu: [{198id: MenuId.ExplorerContext,199group: 'navigation',200order: 25,201when: ContextKeyExpr.and(202ContextKeyExpr.has('config.imageCarousel.explorerContextMenu.enabled'),203ContextKeyExpr.or(204ExplorerFolderContext,205ContextKeyExpr.regex(ResourceContextKey.Extension.key, MEDIA_EXTENSION_REGEX),206),207),208}],209});210}211212async run(accessor: ServicesAccessor, resource?: URI): Promise<void> {213const explorerService = accessor.get(IExplorerService);214const fileService = accessor.get(IFileService);215const editorService = accessor.get(IEditorService);216const notificationService = accessor.get(INotificationService);217const contextService = accessor.get(IWorkspaceContextService);218219const context = explorerService.getContext(true);220221let imageUris: URI[] = [];222let startUri: URI | undefined;223224try {225if (context.length === 0) {226// Empty-space right-click: the explorer passes the workspace root227// as the resource argument. Fall back to the first workspace folder228// when no resource is available.229let folderUri: URI | undefined;230if (URI.isUri(resource)) {231folderUri = resource;232} else {233const folders = contextService.getWorkspace().folders;234if (folders.length > 0) {235folderUri = folders[0].uri;236}237}238239if (folderUri) {240imageUris = await collectImageFilesFromFolder(fileService, folderUri);241}242} else {243const hasSingleImageFile = context.length === 1 && !context[0].isDirectory && isMediaResource(context[0].resource);244245if (hasSingleImageFile) {246// Single image: show all sibling images in the same folder with247// the selected image focused248startUri = context[0].resource;249const parentUri = dirname(context[0].resource);250imageUris = await collectImageFilesFromFolder(fileService, parentUri);251} else {252// Multiple items or a folder: collect images from selection,253// deduplicating in case a folder and its children are both selected254const seen = new ResourceSet();255for (const item of context) {256if (item.isDirectory) {257const folderImages = await collectImageFilesFromFolder(fileService, item.resource);258for (const uri of folderImages) {259if (!seen.has(uri)) {260seen.add(uri);261imageUris.push(uri);262}263}264} else if (isMediaResource(item.resource)) {265if (!seen.has(item.resource)) {266seen.add(item.resource);267imageUris.push(item.resource);268if (!startUri) {269startUri = item.resource;270}271}272}273}274}275}276} catch {277notificationService.error(localize('folderReadError', "Could not read folder contents."));278return;279}280281if (imageUris.length === 0) {282notificationService.info(localize('noImagesFound', "No images found in this folder."));283return;284}285286const images = createImageEntries(imageUris);287288let startIndex = 0;289if (startUri) {290const idx = images.findIndex(img => img.uri?.toString() === startUri!.toString());291if (idx >= 0) {292startIndex = idx;293}294}295296const collection: IImageCarouselCollection = {297id: generateUuid(),298title: localize('imageCarousel.explorerTitle', "Images Preview"),299sections: [{300title: '',301images,302}],303};304305const input = new ImageCarouselEditorInput(collection, startIndex);306await editorService.openEditor(input, { pinned: true });307}308}309310registerAction2(OpenImagesInCarouselFromExplorerAction);311312313