Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts
13401 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import './media/imageCarousel.css';
7
import { localize, localize2 } from '../../../../nls.js';
8
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
9
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
10
import { Registry } from '../../../../platform/registry/common/platform.js';
11
import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js';
12
import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../common/editor.js';
13
import { IEditorService } from '../../../services/editor/common/editorService.js';
14
import { VSBuffer } from '../../../../base/common/buffer.js';
15
import { generateUuid } from '../../../../base/common/uuid.js';
16
import { ImageCarouselEditor } from './imageCarouselEditor.js';
17
import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js';
18
import { ICarouselImage, IImageCarouselCollection } from './imageCarouselTypes.js';
19
import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js';
20
import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js';
21
import { ExplorerFolderContext } from '../../files/common/files.js';
22
import { IExplorerService } from '../../files/browser/files.js';
23
import { ResourceContextKey } from '../../../common/contextkeys.js';
24
import { IFileService } from '../../../../platform/files/common/files.js';
25
import { getMediaMime } from '../../../../base/common/mime.js';
26
import { URI } from '../../../../base/common/uri.js';
27
import { basename, dirname, extname } from '../../../../base/common/resources.js';
28
import { ResourceSet } from '../../../../base/common/map.js';
29
import { INotificationService } from '../../../../platform/notification/common/notification.js';
30
import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js';
31
import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js';
32
33
// --- Configuration ---
34
35
Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({
36
id: 'imageCarousel',
37
title: localize('imageCarouselConfigurationTitle', "Images Preview"),
38
type: 'object',
39
properties: {
40
'imageCarousel.explorerContextMenu.enabled': {
41
type: 'boolean',
42
default: true,
43
markdownDescription: localize('imageCarousel.explorerContextMenu.enabled', "Controls whether the **Open in Images Preview** option appears in the Explorer context menu."),
44
tags: ['experimental'],
45
},
46
'imageCarousel.chat.enabled': {
47
type: 'boolean',
48
default: true,
49
description: localize('imageCarousel.chat.enabled', "Controls whether clicking an image attachment in chat opens the Images Preview viewer."),
50
tags: ['experimental'],
51
},
52
}
53
});
54
55
// --- Editor Pane Registration ---
56
57
Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(
58
EditorPaneDescriptor.create(
59
ImageCarouselEditor,
60
ImageCarouselEditor.ID,
61
localize('imageCarouselEditor', "Images Preview")
62
),
63
[
64
new SyncDescriptor(ImageCarouselEditorInput)
65
]
66
);
67
68
// --- Serializer ---
69
70
class ImageCarouselEditorInputSerializer implements IEditorSerializer {
71
canSerialize(): boolean {
72
return false;
73
}
74
75
serialize(): string | undefined {
76
return undefined;
77
}
78
79
deserialize(): ImageCarouselEditorInput | undefined {
80
return undefined;
81
}
82
}
83
84
Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory)
85
.registerEditorSerializer(ImageCarouselEditorInput.ID, ImageCarouselEditorInputSerializer);
86
87
// --- Args Types ---
88
89
interface IOpenCarouselCollectionArgs {
90
readonly collection: IImageCarouselCollection;
91
readonly startIndex: number;
92
}
93
94
interface IOpenCarouselSingleImageArgs {
95
readonly name: string;
96
readonly mimeType: string;
97
readonly data: Uint8Array;
98
readonly title?: string;
99
}
100
101
function isCollectionArgs(args: unknown): args is IOpenCarouselCollectionArgs {
102
return typeof args === 'object' && args !== null
103
&& typeof (args as IOpenCarouselCollectionArgs).collection === 'object'
104
&& typeof (args as IOpenCarouselCollectionArgs).startIndex === 'number';
105
}
106
107
function isSingleImageArgs(args: unknown): args is IOpenCarouselSingleImageArgs {
108
return typeof args === 'object' && args !== null
109
&& typeof (args as IOpenCarouselSingleImageArgs).name === 'string'
110
&& typeof (args as IOpenCarouselSingleImageArgs).mimeType === 'string'
111
&& (args as IOpenCarouselSingleImageArgs).data instanceof Uint8Array;
112
}
113
114
// --- Actions ---
115
116
class OpenImageInCarouselAction extends Action2 {
117
constructor() {
118
super({
119
id: 'workbench.action.chat.openImageInCarousel',
120
title: localize2('openImageInCarousel', "Open in Images Preview"),
121
f1: false
122
});
123
}
124
125
async run(accessor: ServicesAccessor, args?: unknown): Promise<void> {
126
const editorService = accessor.get(IEditorService);
127
128
let collection: IImageCarouselCollection;
129
let startIndex: number;
130
131
if (isCollectionArgs(args)) {
132
collection = args.collection;
133
startIndex = args.startIndex;
134
} else if (isSingleImageArgs(args)) {
135
collection = {
136
id: generateUuid(),
137
title: args.title ?? localize('imageCarousel.title', "Images Preview"),
138
sections: [{
139
title: '',
140
images: [{
141
id: generateUuid(),
142
name: args.name,
143
mimeType: args.mimeType,
144
data: VSBuffer.wrap(args.data),
145
}],
146
}],
147
};
148
startIndex = 0;
149
} else {
150
return;
151
}
152
153
const input = new ImageCarouselEditorInput(collection, startIndex);
154
await editorService.openEditor(input, { pinned: true });
155
}
156
}
157
158
registerAction2(OpenImageInCarouselAction);
159
160
// --- Explorer Context Menu Integration ---
161
162
/** Supported media (image + video) extensions for the carousel explorer context menu. */
163
const MEDIA_EXTENSION_REGEX = /^\.(png|jpg|jpeg|jpe|gif|webp|svg|bmp|ico|mp4|webm|mov)$/i;
164
165
function isMediaResource(uri: URI): boolean {
166
return MEDIA_EXTENSION_REGEX.test(extname(uri));
167
}
168
169
async function collectImageFilesFromFolder(fileService: IFileService, folderUri: URI): Promise<URI[]> {
170
const stat = await fileService.resolve(folderUri);
171
const imageUris: URI[] = [];
172
if (stat.children) {
173
for (const child of stat.children) {
174
if (child.isFile && isMediaResource(child.resource)) {
175
imageUris.push(child.resource);
176
}
177
}
178
}
179
imageUris.sort((a, b) => basename(a).localeCompare(basename(b)));
180
return imageUris;
181
}
182
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
}));
190
}
191
192
class OpenImagesInCarouselFromExplorerAction extends Action2 {
193
constructor() {
194
super({
195
id: 'workbench.action.openImagesInCarousel',
196
title: localize2('openImagesInCarousel', "Open in Images Preview"),
197
f1: false,
198
menu: [{
199
id: MenuId.ExplorerContext,
200
group: 'navigation',
201
order: 25,
202
when: ContextKeyExpr.and(
203
ContextKeyExpr.has('config.imageCarousel.explorerContextMenu.enabled'),
204
ContextKeyExpr.or(
205
ExplorerFolderContext,
206
ContextKeyExpr.regex(ResourceContextKey.Extension.key, MEDIA_EXTENSION_REGEX),
207
),
208
),
209
}],
210
});
211
}
212
213
async run(accessor: ServicesAccessor, resource?: URI): Promise<void> {
214
const explorerService = accessor.get(IExplorerService);
215
const fileService = accessor.get(IFileService);
216
const editorService = accessor.get(IEditorService);
217
const notificationService = accessor.get(INotificationService);
218
const contextService = accessor.get(IWorkspaceContextService);
219
220
const context = explorerService.getContext(true);
221
222
let imageUris: URI[] = [];
223
let startUri: URI | undefined;
224
225
try {
226
if (context.length === 0) {
227
// Empty-space right-click: the explorer passes the workspace root
228
// as the resource argument. Fall back to the first workspace folder
229
// when no resource is available.
230
let folderUri: URI | undefined;
231
if (URI.isUri(resource)) {
232
folderUri = resource;
233
} else {
234
const folders = contextService.getWorkspace().folders;
235
if (folders.length > 0) {
236
folderUri = folders[0].uri;
237
}
238
}
239
240
if (folderUri) {
241
imageUris = await collectImageFilesFromFolder(fileService, folderUri);
242
}
243
} else {
244
const hasSingleImageFile = context.length === 1 && !context[0].isDirectory && isMediaResource(context[0].resource);
245
246
if (hasSingleImageFile) {
247
// Single image: show all sibling images in the same folder with
248
// the selected image focused
249
startUri = context[0].resource;
250
const parentUri = dirname(context[0].resource);
251
imageUris = await collectImageFilesFromFolder(fileService, parentUri);
252
} else {
253
// Multiple items or a folder: collect images from selection,
254
// deduplicating in case a folder and its children are both selected
255
const seen = new ResourceSet();
256
for (const item of context) {
257
if (item.isDirectory) {
258
const folderImages = await collectImageFilesFromFolder(fileService, item.resource);
259
for (const uri of folderImages) {
260
if (!seen.has(uri)) {
261
seen.add(uri);
262
imageUris.push(uri);
263
}
264
}
265
} else if (isMediaResource(item.resource)) {
266
if (!seen.has(item.resource)) {
267
seen.add(item.resource);
268
imageUris.push(item.resource);
269
if (!startUri) {
270
startUri = item.resource;
271
}
272
}
273
}
274
}
275
}
276
}
277
} catch {
278
notificationService.error(localize('folderReadError', "Could not read folder contents."));
279
return;
280
}
281
282
if (imageUris.length === 0) {
283
notificationService.info(localize('noImagesFound', "No images found in this folder."));
284
return;
285
}
286
287
const images = createImageEntries(imageUris);
288
289
let startIndex = 0;
290
if (startUri) {
291
const idx = images.findIndex(img => img.uri?.toString() === startUri!.toString());
292
if (idx >= 0) {
293
startIndex = idx;
294
}
295
}
296
297
const collection: IImageCarouselCollection = {
298
id: generateUuid(),
299
title: localize('imageCarousel.explorerTitle', "Images Preview"),
300
sections: [{
301
title: '',
302
images,
303
}],
304
};
305
306
const input = new ImageCarouselEditorInput(collection, startIndex);
307
await editorService.openEditor(input, { pinned: true });
308
}
309
}
310
311
registerAction2(OpenImagesInCarouselFromExplorerAction);
312
313