customEditors.ts 20.6 KB
Newer Older
1 2 3 4 5
/*---------------------------------------------------------------------------------------------
 *  Copyright (c) Microsoft Corporation. All rights reserved.
 *  Licensed under the MIT License. See License.txt in the project root for license information.
 *--------------------------------------------------------------------------------------------*/

6
import { coalesce } from 'vs/base/common/arrays';
7
import { Emitter } from 'vs/base/common/event';
8
import { Lazy } from 'vs/base/common/lazy';
9
import { Disposable } from 'vs/base/common/lifecycle';
10
import { basename, extname, isEqual } from 'vs/base/common/resources';
11 12 13 14
import { URI } from 'vs/base/common/uri';
import { generateUuid } from 'vs/base/common/uuid';
import * as nls from 'vs/nls';
import { IConfigurationService } from 'vs/platform/configuration/common/configuration';
15
import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey';
16
import { EditorActivation, IEditorOptions, ITextEditorOptions } from 'vs/platform/editor/common/editor';
17
import { FileOperation, IFileService } from 'vs/platform/files/common/files';
18 19
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IQuickInputService, IQuickPickItem } from 'vs/platform/quickinput/common/quickInput';
M
Matt Bierner 已提交
20 21
import * as colorRegistry from 'vs/platform/theme/common/colorRegistry';
import { registerThemingParticipant } from 'vs/platform/theme/common/themeService';
22
import { EditorServiceImpl } from 'vs/workbench/browser/parts/editor/editor';
23
import { IWorkbenchContribution } from 'vs/workbench/common/contributions';
24
import { EditorInput, EditorOptions, GroupIdentifier, IEditorInput, IEditorPane } from 'vs/workbench/common/editor';
25
import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput';
26
import { webviewEditorsExtensionPoint } from 'vs/workbench/contrib/customEditor/browser/extensionPoint';
27
import { CONTEXT_CUSTOM_EDITORS, CONTEXT_FOCUSED_CUSTOM_EDITOR_IS_EDITABLE, CustomEditorInfo, CustomEditorInfoCollection, CustomEditorPriority, CustomEditorSelector, ICustomEditorService } from 'vs/workbench/contrib/customEditor/common/customEditor';
28
import { CustomEditorModelManager } from 'vs/workbench/contrib/customEditor/common/customEditorModelManager';
29
import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput';
30
import { IWebviewService, webviewHasOwnEditFunctionsContext } from 'vs/workbench/contrib/webview/browser/webview';
31
import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
32
import { IEditorService, IOpenEditorOverride } from 'vs/workbench/services/editor/common/editorService';
33
import { CustomEditorInput } from './customEditorInput';
34

35
export const defaultEditorId = 'default';
36

37
const defaultEditorInfo = new CustomEditorInfo({
38
	id: defaultEditorId,
39
	displayName: nls.localize('promptOpenWith.defaultEditor', "VS Code's standard text editor"),
40 41 42
	selector: [
		{ filenamePattern: '*' }
	],
43
	priority: CustomEditorPriority.default,
44
});
45

46
export class CustomEditorInfoStore extends Disposable {
47

48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67
	private readonly _contributedEditors = new Map<string, CustomEditorInfo>();

	constructor() {
		super();

		webviewEditorsExtensionPoint.setHandler(extensions => {
			this._contributedEditors.clear();

			for (const extension of extensions) {
				for (const webviewEditorContribution of extension.value) {
					this.add(new CustomEditorInfo({
						id: webviewEditorContribution.viewType,
						displayName: webviewEditorContribution.displayName,
						selector: webviewEditorContribution.selector || [],
						priority: webviewEditorContribution.priority || CustomEditorPriority.default,
					}));
				}
			}
			this._onChange.fire();
		});
68 69
	}

70 71 72
	private readonly _onChange = this._register(new Emitter<void>());
	public readonly onChange = this._onChange.event;

73 74 75
	public get(viewType: string): CustomEditorInfo | undefined {
		return viewType === defaultEditorId
			? defaultEditorInfo
76
			: this._contributedEditors.get(viewType);
77 78
	}

79 80 81
	public getContributedEditors(resource: URI): readonly CustomEditorInfo[] {
		return Array.from(this._contributedEditors.values())
			.filter(customEditor => customEditor.matches(resource));
82 83
	}

84 85 86 87 88 89
	private add(info: CustomEditorInfo): void {
		if (info.id === defaultEditorId || this._contributedEditors.has(info.id)) {
			console.error(`Custom editor with id '${info.id}' already registered`);
			return;
		}
		this._contributedEditors.set(info.id, info);
90 91 92
	}
}

93
export class CustomEditorService extends Disposable implements ICustomEditorService {
94 95
	_serviceBrand: any;

96
	private readonly _editorInfoStore = this._register(new CustomEditorInfoStore());
97

98
	private readonly _models = new CustomEditorModelManager();
99

100
	private readonly _customEditorContextKey: IContextKey<string>;
101
	private readonly _focusedCustomEditorIsEditable: IContextKey<boolean>;
102
	private readonly _webviewHasOwnEditFunctions: IContextKey<boolean>;
103 104

	constructor(
105
		@IContextKeyService contextKeyService: IContextKeyService,
106
		@IFileService fileService: IFileService,
107
		@IConfigurationService private readonly configurationService: IConfigurationService,
108
		@IEditorService private readonly editorService: IEditorService,
109
		@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,
110 111
		@IInstantiationService private readonly instantiationService: IInstantiationService,
		@IQuickInputService private readonly quickInputService: IQuickInputService,
112
		@IWebviewService private readonly webviewService: IWebviewService,
113
	) {
114 115
		super();

116
		this._customEditorContextKey = CONTEXT_CUSTOM_EDITORS.bindTo(contextKeyService);
117
		this._focusedCustomEditorIsEditable = CONTEXT_FOCUSED_CUSTOM_EDITOR_IS_EDITABLE.bindTo(contextKeyService);
118
		this._webviewHasOwnEditFunctions = webviewHasOwnEditFunctionsContext.bindTo(contextKeyService);
119

120
		this._register(this._editorInfoStore.onChange(() => this.updateContexts()));
121
		this._register(this.editorService.onDidActiveEditorChange(() => this.updateContexts()));
122

123 124 125 126 127 128
		this._register(fileService.onDidRunOperation(e => {
			if (e.isOperation(FileOperation.MOVE)) {
				this.handleMovedFileInOpenedFileEditors(e.resource, e.target.resource);
			}
		}));

129 130 131
		this.updateContexts();
	}

132 133
	public get models() { return this._models; }

134 135 136 137
	public getCustomEditor(viewType: string): CustomEditorInfo | undefined {
		return this._editorInfoStore.get(viewType);
	}

138 139
	public getContributedCustomEditors(resource: URI): CustomEditorInfoCollection {
		return new CustomEditorInfoCollection(this._editorInfoStore.getContributedEditors(resource));
140 141
	}

142
	public getUserConfiguredCustomEditors(resource: URI): CustomEditorInfoCollection {
143
		const rawAssociations = this.configurationService.getValue<CustomEditorsAssociations>(customEditorsAssociationsKey) || [];
144 145 146 147
		return new CustomEditorInfoCollection(
			coalesce(rawAssociations
				.filter(association => CustomEditorInfo.selectorMatches(association, resource))
				.map(association => this._editorInfoStore.get(association.viewType))));
148 149
	}

150 151 152 153 154 155 156
	public getAllCustomEditors(resource: URI): CustomEditorInfoCollection {
		return new CustomEditorInfoCollection([
			...this.getUserConfiguredCustomEditors(resource).allEditors,
			...this.getContributedCustomEditors(resource).allEditors,
		]);
	}

157 158 159 160
	public async promptOpenWith(
		resource: URI,
		options?: ITextEditorOptions,
		group?: IEditorGroup,
161
	): Promise<IEditorPane | undefined> {
162 163 164 165 166 167 168 169 170 171 172 173
		const pick = await this.showOpenWithPrompt(resource, group);
		if (!pick) {
			return;
		}

		return this.openWith(resource, pick, options, group);
	}

	private showOpenWithPrompt(
		resource: URI,
		group?: IEditorGroup,
	): Promise<string | undefined> {
174
		const customEditors = new CustomEditorInfoCollection([
175
			defaultEditorInfo,
176
			...this.getAllCustomEditors(resource).allEditors,
177
		]);
178

179 180
		let currentlyOpenedEditorType: undefined | string;
		for (const editor of group ? group.editors : []) {
181
			if (editor.resource && isEqual(editor.resource, resource)) {
182
				currentlyOpenedEditorType = editor instanceof CustomEditorInput ? editor.viewType : defaultEditorId;
183 184 185 186
				break;
			}
		}

187 188
		const resourceExt = extname(resource);

189
		const items = customEditors.allEditors.map((editorDescriptor): IQuickPickItem => ({
190 191 192 193
			label: editorDescriptor.displayName,
			id: editorDescriptor.id,
			description: editorDescriptor.id === currentlyOpenedEditorType
				? nls.localize('openWithCurrentlyActive', "Currently Active")
194 195 196 197 198
				: undefined,
			buttons: resourceExt ? [{
				iconClass: 'codicon-settings-gear',
				tooltip: nls.localize('promptOpenWith.setDefaultTooltip', "Set as default editor for '{0}' files", resourceExt)
			}] : undefined
199
		}));
200 201 202 203 204

		const picker = this.quickInputService.createQuickPick();
		picker.items = items;
		picker.placeholder = nls.localize('promptOpenWith.placeHolder', "Select editor to use for '{0}'...", basename(resource));

205
		return new Promise<string | undefined>(resolve => {
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235
			picker.onDidAccept(() => {
				resolve(picker.selectedItems.length === 1 ? picker.selectedItems[0].id : undefined);
				picker.dispose();
			});
			picker.onDidTriggerItemButton(e => {
				const pick = e.item.id;
				resolve(pick); // open the view
				picker.dispose();

				// And persist the setting
				if (pick) {
					const newAssociation: CustomEditorAssociation = { viewType: pick, filenamePattern: '*' + resourceExt };
					const currentAssociations = [...this.configurationService.getValue<CustomEditorsAssociations>(customEditorsAssociationsKey)] || [];

					// First try updating existing association
					for (let i = 0; i < currentAssociations.length; ++i) {
						const existing = currentAssociations[i];
						if (existing.filenamePattern === newAssociation.filenamePattern) {
							currentAssociations.splice(i, 1, newAssociation);
							this.configurationService.updateValue(customEditorsAssociationsKey, currentAssociations);
							return;
						}
					}

					// Otherwise, create a new one
					currentAssociations.unshift(newAssociation);
					this.configurationService.updateValue(customEditorsAssociationsKey, currentAssociations);
				}
			});
			picker.show();
236 237 238 239 240 241 242 243
		});
	}

	public openWith(
		resource: URI,
		viewType: string,
		options?: ITextEditorOptions,
		group?: IEditorGroup,
244
	): Promise<IEditorPane | undefined> {
245
		if (viewType === defaultEditorId) {
246 247
			const fileEditorInput = this.editorService.createEditorInput({ resource, forceFile: true });
			return this.openEditorForResource(resource, fileEditorInput, { ...options, ignoreOverrides: true }, group);
248 249
		}

250
		if (!this._editorInfoStore.get(viewType)) {
251 252 253
			return this.promptOpenWith(resource, options, group);
		}

254
		const input = this.createInput(resource, viewType, group?.id);
255 256 257 258 259 260
		return this.openEditorForResource(resource, input, options, group);
	}

	public createInput(
		resource: URI,
		viewType: string,
261
		group: GroupIdentifier | undefined,
262
		options?: { readonly customClasses: string; },
263
	): IEditorInput {
M
Matt Bierner 已提交
264
		if (viewType === defaultEditorId) {
265
			return this.editorService.createEditorInput({ resource, forceFile: true });
M
Matt Bierner 已提交
266 267
		}

268
		const id = generateUuid();
269
		const webview = new Lazy(() => {
270
			return this.webviewService.createWebviewOverlay(id, { customClasses: options?.customClasses }, {});
271
		});
272
		const input = this.instantiationService.createInstance(CustomEditorInput, resource, viewType, id, webview, false);
273 274
		if (typeof group !== 'undefined') {
			input.updateGroup(group);
275
		}
276
		return input;
277 278 279 280 281 282 283
	}

	private async openEditorForResource(
		resource: URI,
		input: IEditorInput,
		options?: IEditorOptions,
		group?: IEditorGroup
284
	): Promise<IEditorPane | undefined> {
285 286
		const targetGroup = group || this.editorGroupService.activeGroup;

287 288 289 290
		if (options && typeof options.activation === 'undefined') {
			options = { ...options, activation: options.preserveFocus ? EditorActivation.RESTORE : undefined };
		}

291
		// Try to replace existing editors for resource
292
		const existingEditors = targetGroup.editors.filter(editor => editor.resource && isEqual(editor.resource, resource));
293 294 295 296 297 298 299 300 301
		if (existingEditors.length) {
			const existing = existingEditors[0];
			if (!input.matches(existing)) {
				await this.editorService.replaceEditors([{
					editor: existing,
					replacement: input,
					options: options ? EditorOptions.create(options) : undefined,
				}], targetGroup);

302
				if (existing instanceof CustomEditorInput) {
303
					existing.dispose();
304
				}
305 306
			}
		}
307

308 309
		return this.editorService.openEditor(input, options, group);
	}
310

311
	private updateContexts() {
312
		const activeEditorPane = this.editorService.activeEditorPane;
313
		const resource = activeEditorPane?.input?.resource;
314
		if (!resource) {
315
			this._customEditorContextKey.reset();
316
			this._focusedCustomEditorIsEditable.reset();
317
			this._webviewHasOwnEditFunctions.reset();
318 319
			return;
		}
320

321 322
		const possibleEditors = this.getAllCustomEditors(resource).allEditors;

323
		this._customEditorContextKey.set(possibleEditors.map(x => x.id).join(','));
324
		this._focusedCustomEditorIsEditable.set(activeEditorPane?.input instanceof CustomEditorInput);
325
		this._webviewHasOwnEditFunctions.set(possibleEditors.length > 0);
326
	}
327

328 329 330 331 332
	private async handleMovedFileInOpenedFileEditors(oldResource: URI, newResource: URI): Promise<void> {
		if (extname(oldResource) === extname(newResource)) {
			return;
		}

333
		// See if the new resource can be opened in a custom editor
334 335 336
		if (!this.getAllCustomEditors(newResource).allEditors
			.some(editor => editor.priority !== CustomEditorPriority.option)
		) {
337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361
			return;
		}

		// If so, check all editors to see if there are any file editors open for the new resource
		const editorsToReplace = new Map<GroupIdentifier, IEditorInput[]>();
		for (const group of this.editorGroupService.groups) {
			for (const editor of group.editors) {
				if (editor instanceof FileEditorInput
					&& !(editor instanceof CustomEditorInput)
					&& isEqual(editor.resource, newResource)
				) {
					let entry = editorsToReplace.get(group.id);
					if (!entry) {
						entry = [];
						editorsToReplace.set(group.id, entry);
					}
					entry.push(editor);
				}
			}
		}

		if (!editorsToReplace.size) {
			return;
		}

362 363 364 365 366 367 368
		// If there is, show a single prompt for all editors to see if the user wants to re-open them
		//
		// TODO: instead of prompting eagerly, it'd likly be better to replace all the editors with
		// ones that would prompt when they first become visible
		await new Promise(resolve => setTimeout(resolve, 50));
		const pickedViewType = await this.showOpenWithPrompt(newResource);
		if (!pickedViewType) {
369 370 371 372 373
			return;
		}

		for (const [group, entries] of editorsToReplace) {
			this.editorService.replaceEditors(entries.map(editor => {
374
				const replacement = this.createInput(newResource, pickedViewType, group);
375 376 377 378 379 380 381 382 383 384
				return {
					editor,
					replacement,
					options: {
						preserveFocus: true,
					}
				};
			}), group);
		}
	}
385 386 387 388
}

export const customEditorsAssociationsKey = 'workbench.experimental.editorAssociations';

389 390 391 392 393
export type CustomEditorAssociation = CustomEditorSelector & {
	readonly viewType: string;
};

export type CustomEditorsAssociations = readonly CustomEditorAssociation[];
394

395
export class CustomEditorContribution extends Disposable implements IWorkbenchContribution {
396
	constructor(
397
		@IEditorService private readonly editorService: EditorServiceImpl,
398 399
		@ICustomEditorService private readonly customEditorService: ICustomEditorService,
	) {
400 401 402 403 404 405 406
		super();

		this._register(this.editorService.overrideOpenEditor((editor, options, group) => {
			return this.onEditorOpening(editor, options, group);
		}));

		this._register(this.editorService.onDidCloseEditor(({ editor }) => {
407
			if (!(editor instanceof CustomEditorInput)) {
408 409 410 411 412 413 414
				return;
			}

			if (!this.editorService.editors.some(other => other === editor)) {
				editor.dispose();
			}
		}));
415 416 417 418 419 420 421
	}

	private onEditorOpening(
		editor: IEditorInput,
		options: ITextEditorOptions | undefined,
		group: IEditorGroup
	): IOpenEditorOverride | undefined {
422
		if (editor instanceof CustomEditorInput) {
423
			if (editor.group === group.id) {
424
				// No need to do anything
425
				return undefined;
426 427 428 429 430
			} else {
				// Create a copy of the input.
				// Unlike normal editor inputs, we do not want to share custom editor inputs
				// between multiple editors / groups.
				return {
431
					override: this.customEditorService.openWith(editor.resource, editor.viewType, options, group)
432
				};
433
			}
434
		}
435 436

		if (editor instanceof DiffEditorInput) {
M
Matt Bierner 已提交
437
			return this.onDiffEditorOpening(editor, options, group);
438
		}
439

440
		const resource = editor.resource;
M
Matt Bierner 已提交
441 442
		if (resource) {
			return this.onResourceEditorOpening(resource, editor, options, group);
443
		}
M
Matt Bierner 已提交
444 445
		return undefined;
	}
446

M
Matt Bierner 已提交
447 448 449 450 451 452
	private onResourceEditorOpening(
		resource: URI,
		editor: IEditorInput,
		options: ITextEditorOptions | undefined,
		group: IEditorGroup
	): IOpenEditorOverride | undefined {
453 454 455 456 457 458
		const userConfiguredEditors = this.customEditorService.getUserConfiguredCustomEditors(resource);
		const contributedEditors = this.customEditorService.getContributedCustomEditors(resource);
		if (!userConfiguredEditors.length && !contributedEditors.length) {
			return;
		}

459 460
		// Check to see if there already an editor for the resource in the group.
		// If there is, we want to open that instead of creating a new editor.
461
		// This ensures that we preserve whatever type of editor was previously being used
462
		// when the user switches back to it.
463
		const existingEditorForResource = group.editors.find(editor => isEqual(resource, editor.resource));
464 465
		if (existingEditorForResource) {
			return {
466 467 468 469 470
				override: this.editorService.openEditor(existingEditorForResource, {
					...options,
					ignoreOverrides: true,
					activation: options?.preserveFocus ? EditorActivation.RESTORE : undefined,
				}, group)
471 472 473
			};
		}

M
Matt Bierner 已提交
474 475
		if (userConfiguredEditors.length) {
			return {
476
				override: this.customEditorService.openWith(resource, userConfiguredEditors.allEditors[0].id, options, group),
M
Matt Bierner 已提交
477
			};
478 479
		}

M
Matt Bierner 已提交
480 481
		if (!contributedEditors.length) {
			return;
482 483
		}

484
		const defaultEditor = contributedEditors.defaultEditor;
M
Matt Bierner 已提交
485
		if (defaultEditor) {
486
			return {
M
Matt Bierner 已提交
487
				override: this.customEditorService.openWith(resource, defaultEditor.id, options, group),
488 489 490
			};
		}

491
		// If we have all optional editors, then open VS Code's standard editor
492
		if (contributedEditors.allEditors.every(editor => editor.priority === CustomEditorPriority.option)) {
493 494 495
			return;
		}

M
Matt Bierner 已提交
496
		// Open VS Code's standard editor but prompt user to see if they wish to use a custom one instead
497 498 499
		return {
			override: (async () => {
				const standardEditor = await this.editorService.openEditor(editor, { ...options, ignoreOverrides: true }, group);
500 501 502
				// Give a moment to make sure the editor is showing.
				// Otherwise the focus shift can cause the prompt to be dismissed right away.
				await new Promise(resolve => setTimeout(resolve, 20));
503 504 505 506 507 508 509 510 511 512 513 514 515
				const selectedEditor = await this.customEditorService.promptOpenWith(resource, options, group);
				if (selectedEditor && selectedEditor.input) {
					await group.replaceEditors([{
						editor,
						replacement: selectedEditor.input
					}]);
					return selectedEditor;
				}

				return standardEditor;
			})()
		};
	}
M
Matt Bierner 已提交
516 517 518 519 520 521 522

	private onDiffEditorOpening(
		editor: DiffEditorInput,
		options: ITextEditorOptions | undefined,
		group: IEditorGroup
	): IOpenEditorOverride | undefined {
		const getCustomEditorOverrideForSubInput = (subInput: IEditorInput, customClasses: string): EditorInput | undefined => {
523
			if (subInput instanceof CustomEditorInput) {
M
Matt Bierner 已提交
524 525
				return undefined;
			}
526
			const resource = subInput.resource;
M
Matt Bierner 已提交
527 528 529 530
			if (!resource) {
				return undefined;
			}

531
			// Prefer default editors in the diff editor case but ultimatly always take the first editor
532 533 534 535 536 537 538
			const allEditors = new CustomEditorInfoCollection([
				...this.customEditorService.getUserConfiguredCustomEditors(resource).allEditors,
				...this.customEditorService.getContributedCustomEditors(resource).allEditors.filter(x => x.priority !== CustomEditorPriority.option),
			]);

			const bestAvailableEditor = allEditors.bestAvailableEditor;
			if (!bestAvailableEditor) {
M
Matt Bierner 已提交
539 540
				return undefined;
			}
541

542
			const input = this.customEditorService.createInput(resource, bestAvailableEditor.id, group.id, { customClasses });
543 544 545 546 547
			if (input instanceof EditorInput) {
				return input;
			}

			return undefined;
M
Matt Bierner 已提交
548 549 550 551 552 553 554 555
		};

		const modifiedOverride = getCustomEditorOverrideForSubInput(editor.modifiedInput, 'modified');
		const originalOverride = getCustomEditorOverrideForSubInput(editor.originalInput, 'original');

		if (modifiedOverride || originalOverride) {
			return {
				override: (async () => {
556
					const input = new DiffEditorInput(editor.getName(), editor.getDescription(), originalOverride || editor.originalInput, modifiedOverride || editor.modifiedInput, true);
M
Matt Bierner 已提交
557 558 559 560 561 562 563
					return this.editorService.openEditor(input, { ...options, ignoreOverrides: true }, group);
				})(),
			};
		}

		return undefined;
	}
564 565
}

566 567 568 569 570 571
registerThemingParticipant((theme, collector) => {
	const shadow = theme.getColor(colorRegistry.scrollbarShadow);
	if (shadow) {
		collector.addRule(`.webview.modified { box-shadow: -6px 0 5px -5px ${shadow}; }`);
	}
});