customEditors.ts 20.5 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 { Event, 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 { CONTEXT_CUSTOM_EDITORS, CONTEXT_FOCUSED_CUSTOM_EDITOR_IS_EDITABLE, CustomEditorInfo, CustomEditorInfoCollection, CustomEditorPriority, ICustomEditorService } from 'vs/workbench/contrib/customEditor/common/customEditor';
27
import { CustomEditorModelManager } from 'vs/workbench/contrib/customEditor/common/customEditorModelManager';
28
import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput';
29
import { IWebviewService, webviewHasOwnEditFunctionsContext } from 'vs/workbench/contrib/webview/browser/webview';
30
import { IEditorGroup, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService';
31
import { IEditorService, IOpenEditorOverride, ICustomEditorViewTypesHandler, ICustomEditorInfo, IOpenEditorOverrideEntry } from 'vs/workbench/services/editor/common/editorService';
32
import { ContributedCustomEditors, defaultCustomEditor } from '../common/contributedCustomEditors';
33
import { CustomEditorInput } from './customEditorInput';
34
import { CustomEditorAssociation, CustomEditorsAssociations, customEditorsAssociationsSettingId } from 'vs/workbench/services/editor/browser/editorAssociationsSetting';
35

36
export class CustomEditorService extends Disposable implements ICustomEditorService, ICustomEditorViewTypesHandler {
37 38
	_serviceBrand: any;

39
	private readonly _contributedEditors = this._register(new ContributedCustomEditors());
40

41
	private readonly _models = new CustomEditorModelManager();
42

43
	private readonly _customEditorContextKey: IContextKey<string>;
44
	private readonly _focusedCustomEditorIsEditable: IContextKey<boolean>;
45
	private readonly _webviewHasOwnEditFunctions: IContextKey<boolean>;
46 47
	private readonly _onDidChangeViewTypes = new Emitter<void>();
	onDidChangeViewTypes: Event<void> = this._onDidChangeViewTypes.event;
48 49

	constructor(
50
		@IContextKeyService contextKeyService: IContextKeyService,
51
		@IFileService fileService: IFileService,
52
		@IConfigurationService private readonly configurationService: IConfigurationService,
53
		@IEditorService private readonly editorService: IEditorService,
54
		@IEditorGroupsService private readonly editorGroupService: IEditorGroupsService,
55 56
		@IInstantiationService private readonly instantiationService: IInstantiationService,
		@IQuickInputService private readonly quickInputService: IQuickInputService,
57
		@IWebviewService private readonly webviewService: IWebviewService,
58
	) {
59 60
		super();

61
		this._customEditorContextKey = CONTEXT_CUSTOM_EDITORS.bindTo(contextKeyService);
62
		this._focusedCustomEditorIsEditable = CONTEXT_FOCUSED_CUSTOM_EDITOR_IS_EDITABLE.bindTo(contextKeyService);
63
		this._webviewHasOwnEditFunctions = webviewHasOwnEditFunctionsContext.bindTo(contextKeyService);
64

65
		this._register(this.editorService.registerCustomEditorViewTypesHandler('Custom Editor', this));
66
		this._register(this._contributedEditors.onChange(() => {
67
			this.updateContexts();
68
			this._onDidChangeViewTypes.fire();
69
		}));
70
		this._register(this.editorService.onDidActiveEditorChange(() => this.updateContexts()));
71

72 73 74 75 76 77
		this._register(fileService.onDidRunOperation(e => {
			if (e.isOperation(FileOperation.MOVE)) {
				this.handleMovedFileInOpenedFileEditors(e.resource, e.target.resource);
			}
		}));

78 79 80
		this.updateContexts();
	}

81 82 83 84
	getViewTypes(): ICustomEditorInfo[] {
		return [...this._contributedEditors];
	}

85 86
	public get models() { return this._models; }

87
	public getCustomEditor(viewType: string): CustomEditorInfo | undefined {
88
		return this._contributedEditors.get(viewType);
89 90
	}

91
	public getContributedCustomEditors(resource: URI): CustomEditorInfoCollection {
92
		return new CustomEditorInfoCollection(this._contributedEditors.getContributedEditors(resource));
93 94
	}

95
	public getUserConfiguredCustomEditors(resource: URI): CustomEditorInfoCollection {
96
		const rawAssociations = this.configurationService.getValue<CustomEditorsAssociations>(customEditorsAssociationsSettingId) || [];
97 98 99
		return new CustomEditorInfoCollection(
			coalesce(rawAssociations
				.filter(association => CustomEditorInfo.selectorMatches(association, resource))
100
				.map(association => this._contributedEditors.get(association.viewType))));
101 102
	}

103 104 105 106 107 108 109
	public getAllCustomEditors(resource: URI): CustomEditorInfoCollection {
		return new CustomEditorInfoCollection([
			...this.getUserConfiguredCustomEditors(resource).allEditors,
			...this.getContributedCustomEditors(resource).allEditors,
		]);
	}

110 111 112 113
	public async promptOpenWith(
		resource: URI,
		options?: ITextEditorOptions,
		group?: IEditorGroup,
114
	): Promise<IEditorPane | undefined> {
115 116 117 118 119 120 121 122 123 124 125 126
		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> {
127
		const customEditors = new CustomEditorInfoCollection([
128
			defaultCustomEditor,
129
			...this.getAllCustomEditors(resource).allEditors,
130
		]);
131

132 133
		let currentlyOpenedEditorType: undefined | string;
		for (const editor of group ? group.editors : []) {
134
			if (editor.resource && isEqual(editor.resource, resource)) {
135
				currentlyOpenedEditorType = editor instanceof CustomEditorInput ? editor.viewType : defaultCustomEditor.id;
136 137 138 139
				break;
			}
		}

140 141
		const resourceExt = extname(resource);

142
		const items = customEditors.allEditors.map((editorDescriptor): IQuickPickItem => ({
143 144 145 146
			label: editorDescriptor.displayName,
			id: editorDescriptor.id,
			description: editorDescriptor.id === currentlyOpenedEditorType
				? nls.localize('openWithCurrentlyActive', "Currently Active")
147
				: undefined,
148
			detail: editorDescriptor.providerDisplayName,
149 150 151 152
			buttons: resourceExt ? [{
				iconClass: 'codicon-settings-gear',
				tooltip: nls.localize('promptOpenWith.setDefaultTooltip', "Set as default editor for '{0}' files", resourceExt)
			}] : undefined
153
		}));
154 155 156 157 158

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

159
		return new Promise<string | undefined>(resolve => {
160 161 162 163 164 165 166 167 168 169 170 171
			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 };
172
					const currentAssociations = [...this.configurationService.getValue<CustomEditorsAssociations>(customEditorsAssociationsSettingId)] || [];
173 174 175 176 177 178

					// 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);
179
							this.configurationService.updateValue(customEditorsAssociationsSettingId, currentAssociations);
180 181 182 183 184 185
							return;
						}
					}

					// Otherwise, create a new one
					currentAssociations.unshift(newAssociation);
186
					this.configurationService.updateValue(customEditorsAssociationsSettingId, currentAssociations);
187 188 189
				}
			});
			picker.show();
190 191 192 193 194 195 196 197
		});
	}

	public openWith(
		resource: URI,
		viewType: string,
		options?: ITextEditorOptions,
		group?: IEditorGroup,
198
	): Promise<IEditorPane | undefined> {
199
		if (viewType === defaultCustomEditor.id) {
200 201
			const fileEditorInput = this.editorService.createEditorInput({ resource, forceFile: true });
			return this.openEditorForResource(resource, fileEditorInput, { ...options, ignoreOverrides: true }, group);
202 203
		}

204
		if (!this._contributedEditors.get(viewType)) {
205 206 207
			return this.promptOpenWith(resource, options, group);
		}

208
		const input = this.createInput(resource, viewType, group?.id);
209 210 211 212 213 214
		return this.openEditorForResource(resource, input, options, group);
	}

	public createInput(
		resource: URI,
		viewType: string,
215
		group: GroupIdentifier | undefined,
216
		options?: { readonly customClasses: string; },
217
	): IEditorInput {
218
		if (viewType === defaultCustomEditor.id) {
219
			return this.editorService.createEditorInput({ resource, forceFile: true });
M
Matt Bierner 已提交
220 221
		}

222
		const id = generateUuid();
223
		const webview = new Lazy(() => {
224
			return this.webviewService.createWebviewOverlay(id, { customClasses: options?.customClasses }, {});
225
		});
226
		const input = this.instantiationService.createInstance(CustomEditorInput, resource, viewType, id, webview, {});
227 228
		if (typeof group !== 'undefined') {
			input.updateGroup(group);
229
		}
230
		return input;
231 232 233 234 235 236 237
	}

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

241 242 243 244
		if (options && typeof options.activation === 'undefined') {
			options = { ...options, activation: options.preserveFocus ? EditorActivation.RESTORE : undefined };
		}

245
		// Try to replace existing editors for resource
246
		const existingEditors = targetGroup.editors.filter(editor => editor.resource && isEqual(editor.resource, resource));
247 248 249 250 251 252 253 254 255
		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);

256
				if (existing instanceof CustomEditorInput) {
257
					existing.dispose();
258
				}
259 260
			}
		}
261

262 263
		return this.editorService.openEditor(input, options, group);
	}
264

265
	private updateContexts() {
266
		const activeEditorPane = this.editorService.activeEditorPane;
267
		const resource = activeEditorPane?.input?.resource;
268
		if (!resource) {
269
			this._customEditorContextKey.reset();
270
			this._focusedCustomEditorIsEditable.reset();
271
			this._webviewHasOwnEditFunctions.reset();
272 273
			return;
		}
274

275 276
		const possibleEditors = this.getAllCustomEditors(resource).allEditors;

277
		this._customEditorContextKey.set(possibleEditors.map(x => x.id).join(','));
278
		this._focusedCustomEditorIsEditable.set(activeEditorPane?.input instanceof CustomEditorInput);
279
		this._webviewHasOwnEditFunctions.set(possibleEditors.length > 0);
280
	}
281

282 283 284 285 286
	private async handleMovedFileInOpenedFileEditors(oldResource: URI, newResource: URI): Promise<void> {
		if (extname(oldResource) === extname(newResource)) {
			return;
		}

287 288 289 290
		const possibleEditors = this.getAllCustomEditors(newResource);

		// See if we have any non-optional custom editor for this resource
		if (!possibleEditors.allEditors.some(editor => editor.priority !== CustomEditorPriority.option)) {
291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315
			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;
		}

316 317 318 319 320 321 322 323 324 325 326 327 328
		let viewType: string | undefined;
		if (possibleEditors.defaultEditor) {
			viewType = possibleEditors.defaultEditor.id;
		} else {
			// 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));
			viewType = await this.showOpenWithPrompt(newResource);
		}

		if (!viewType) {
329 330 331 332 333
			return;
		}

		for (const [group, entries] of editorsToReplace) {
			this.editorService.replaceEditors(entries.map(editor => {
334
				const replacement = this.createInput(newResource, viewType!, group);
335 336 337 338 339 340 341 342 343 344
				return {
					editor,
					replacement,
					options: {
						preserveFocus: true,
					}
				};
			}), group);
		}
	}
345
}
346

347
export class CustomEditorContribution extends Disposable implements IWorkbenchContribution {
348
	constructor(
349
		@IEditorService private readonly editorService: EditorServiceImpl,
350 351
		@ICustomEditorService private readonly customEditorService: ICustomEditorService,
	) {
352 353
		super();

R
rebornix 已提交
354
		this._register(this.editorService.overrideOpenEditor({
355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372
			open: (editor, options, group, id) => {
				return this.onEditorOpening(editor, options, group, id);
			},
			getEditorOverrides: (editor: IEditorInput, _options: IEditorOptions | undefined, _group: IEditorGroup | undefined): IOpenEditorOverrideEntry[] => {
				const resource = editor.resource;
				if (!resource) {
					return [];
				}

				const customEditors = this.customEditorService.getAllCustomEditors(resource);
				return customEditors.allEditors.map(entry => {
					return {
						id: entry.id,
						active: editor instanceof CustomEditorInput && editor.viewType === entry.id,
						label: entry.displayName,
						detail: entry.providerDisplayName,
					};
				});
R
rebornix 已提交
373
			}
374 375 376
		}));

		this._register(this.editorService.onDidCloseEditor(({ editor }) => {
377
			if (!(editor instanceof CustomEditorInput)) {
378 379 380 381 382 383 384
				return;
			}

			if (!this.editorService.editors.some(other => other === editor)) {
				editor.dispose();
			}
		}));
385 386 387 388 389
	}

	private onEditorOpening(
		editor: IEditorInput,
		options: ITextEditorOptions | undefined,
390 391
		group: IEditorGroup,
		id?: string,
392
	): IOpenEditorOverride | undefined {
393
		if (editor instanceof CustomEditorInput) {
394
			if (editor.group === group.id && (editor.viewType === id || typeof id !== 'string')) {
395
				// No need to do anything
396
				return undefined;
397 398 399 400 401
			} 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 {
402
					override: this.customEditorService.openWith(editor.resource, id ?? editor.viewType, options, group)
403
				};
404
			}
405
		}
406 407

		if (editor instanceof DiffEditorInput) {
M
Matt Bierner 已提交
408
			return this.onDiffEditorOpening(editor, options, group);
409
		}
410

411
		const resource = editor.resource;
412 413
		if (!resource) {
			return undefined;
414
		}
415 416 417 418 419 420 421 422 423 424 425 426

		if (id) {
			if (editor instanceof FileEditorInput && id === defaultCustomEditor.id) {
				return undefined;
			}

			return {
				override: this.customEditorService.openWith(resource, id, { ...options, ignoreOverrides: true }, group)
			};
		}

		return this.onResourceEditorOpening(resource, editor, options, group);
M
Matt Bierner 已提交
427
	}
428

M
Matt Bierner 已提交
429 430 431 432
	private onResourceEditorOpening(
		resource: URI,
		editor: IEditorInput,
		options: ITextEditorOptions | undefined,
433
		group: IEditorGroup,
M
Matt Bierner 已提交
434
	): IOpenEditorOverride | undefined {
435 436 437 438 439 440
		const userConfiguredEditors = this.customEditorService.getUserConfiguredCustomEditors(resource);
		const contributedEditors = this.customEditorService.getContributedCustomEditors(resource);
		if (!userConfiguredEditors.length && !contributedEditors.length) {
			return;
		}

441 442
		// 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.
443
		// This ensures that we preserve whatever type of editor was previously being used
444
		// when the user switches back to it.
445
		const existingEditorForResource = group.editors.find(editor => isEqual(resource, editor.resource));
446
		if (existingEditorForResource) {
447 448 449 450
			if (editor === existingEditorForResource) {
				return;
			}

451
			return {
452 453 454 455 456
				override: this.editorService.openEditor(existingEditorForResource, {
					...options,
					ignoreOverrides: true,
					activation: options?.preserveFocus ? EditorActivation.RESTORE : undefined,
				}, group)
457 458 459
			};
		}

M
Matt Bierner 已提交
460 461
		if (userConfiguredEditors.length) {
			return {
462
				override: this.customEditorService.openWith(resource, userConfiguredEditors.allEditors[0].id, options, group),
M
Matt Bierner 已提交
463
			};
464 465
		}

M
Matt Bierner 已提交
466 467
		if (!contributedEditors.length) {
			return;
468 469
		}

470
		const defaultEditor = contributedEditors.defaultEditor;
M
Matt Bierner 已提交
471
		if (defaultEditor) {
472
			return {
M
Matt Bierner 已提交
473
				override: this.customEditorService.openWith(resource, defaultEditor.id, options, group),
474 475 476
			};
		}

477
		// If we have all optional editors, then open VS Code's standard editor
478
		if (contributedEditors.allEditors.every(editor => editor.priority === CustomEditorPriority.option)) {
479 480 481
			return;
		}

M
Matt Bierner 已提交
482
		// Open VS Code's standard editor but prompt user to see if they wish to use a custom one instead
483 484 485
		return {
			override: (async () => {
				const standardEditor = await this.editorService.openEditor(editor, { ...options, ignoreOverrides: true }, group);
486 487 488
				// 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));
489 490 491 492 493 494 495 496 497 498 499 500 501
				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 已提交
502 503 504 505 506 507 508

	private onDiffEditorOpening(
		editor: DiffEditorInput,
		options: ITextEditorOptions | undefined,
		group: IEditorGroup
	): IOpenEditorOverride | undefined {
		const getCustomEditorOverrideForSubInput = (subInput: IEditorInput, customClasses: string): EditorInput | undefined => {
509
			if (subInput instanceof CustomEditorInput) {
M
Matt Bierner 已提交
510 511
				return undefined;
			}
512
			const resource = subInput.resource;
M
Matt Bierner 已提交
513 514 515 516
			if (!resource) {
				return undefined;
			}

517
			// Prefer default editors in the diff editor case but ultimatly always take the first editor
518 519 520 521 522 523 524
			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 已提交
525 526
				return undefined;
			}
527

528
			const input = this.customEditorService.createInput(resource, bestAvailableEditor.id, group.id, { customClasses });
529 530 531 532 533
			if (input instanceof EditorInput) {
				return input;
			}

			return undefined;
M
Matt Bierner 已提交
534 535 536 537 538 539 540 541
		};

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

		if (modifiedOverride || originalOverride) {
			return {
				override: (async () => {
542
					const input = new DiffEditorInput(editor.getName(), editor.getDescription(), originalOverride || editor.originalInput, modifiedOverride || editor.modifiedInput, true);
M
Matt Bierner 已提交
543 544 545 546 547 548 549
					return this.editorService.openEditor(input, { ...options, ignoreOverrides: true }, group);
				})(),
			};
		}

		return undefined;
	}
550 551
}

552

553 554 555 556 557 558
registerThemingParticipant((theme, collector) => {
	const shadow = theme.getColor(colorRegistry.scrollbarShadow);
	if (shadow) {
		collector.addRule(`.webview.modified { box-shadow: -6px 0 5px -5px ${shadow}; }`);
	}
});