From db3e6caa575402905fbb414cb354cdc00af668f4 Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Thu, 5 Sep 2024 17:09:45 +0200 Subject: [PATCH 1/2] Support `workbench.editorAssociations` preference --- packages/core/src/browser/core-preferences.ts | 9 +++ .../core/src/browser/open-with-service.ts | 59 ++++++++++++++++--- packages/core/src/browser/opener-service.ts | 13 ++++ packages/core/src/common/glob.ts | 8 +-- packages/editor/src/browser/editor-manager.ts | 13 +++- .../src/browser/notebook-open-handler.ts | 20 ++++--- .../custom-editors/custom-editor-opener.tsx | 23 ++++++-- .../plugin-custom-editor-registry.ts | 10 +++- 8 files changed, 127 insertions(+), 28 deletions(-) diff --git a/packages/core/src/browser/core-preferences.ts b/packages/core/src/browser/core-preferences.ts index 04678d4888cb5..c9827c5aaf490 100644 --- a/packages/core/src/browser/core-preferences.ts +++ b/packages/core/src/browser/core-preferences.ts @@ -281,6 +281,15 @@ export const corePreferenceSchema: PreferenceSchema = { default: 200, minimum: 10, description: nls.localize('theia/core/tabDefaultSize', 'Specifies the default size for tabs.') + }, + 'workbench.editorAssociations': { + type: 'object', + markdownDescription: nls.localizeByDefault('Configure [glob patterns](https://aka.ms/vscode-glob-patterns) to editors (for example `"*.hex": "hexEditor.hexedit"`). These have precedence over the default behavior.'), + patternProperties: { + '.*': { + type: 'string' + } + } } } }; diff --git a/packages/core/src/browser/open-with-service.ts b/packages/core/src/browser/open-with-service.ts index c186feb34d559..60218a955f599 100644 --- a/packages/core/src/browser/open-with-service.ts +++ b/packages/core/src/browser/open-with-service.ts @@ -19,7 +19,9 @@ import { Disposable } from '../common/disposable'; import { nls } from '../common/nls'; import { MaybePromise } from '../common/types'; import { URI } from '../common/uri'; -import { QuickInputService } from './quick-input'; +import { match } from '../common/glob'; +import { QuickInputService, QuickPickItem } from './quick-input'; +import { PreferenceScope, PreferenceService } from './preferences'; export interface OpenWithHandler { /** @@ -46,6 +48,11 @@ export interface OpenWithHandler { * A returned value indicating a priority of this handler. */ canHandle(uri: URI): number; + /** + * Test whether this handler and open the given URI + * and return the order of this handler in the list. + */ + getOrder?(uri: URI): number; /** * Open a widget for the given URI and options. * Resolve to an opened widget or undefined, e.g. if a page is opened. @@ -54,12 +61,19 @@ export interface OpenWithHandler { open(uri: URI): MaybePromise; } +export interface OpenWithQuickPickItem extends QuickPickItem { + handler: OpenWithHandler; +} + @injectable() export class OpenWithService { @inject(QuickInputService) protected readonly quickInputService: QuickInputService; + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + protected readonly handlers: OpenWithHandler[] = []; registerHandler(handler: OpenWithHandler): Disposable { @@ -73,17 +87,48 @@ export class OpenWithService { } async openWith(uri: URI): Promise { + const associations: Record = { ...this.preferenceService.get('workbench.editorAssociations') }; + const basename = uri.path.base; + const ext = `*${uri.path.ext}`; const handlers = this.getHandlers(uri); - const result = await this.quickInputService.pick(handlers.map(handler => ({ - handler: handler, - label: handler.label ?? handler.id, - detail: handler.providerName - })), { + const ordered = handlers.slice().sort((a, b) => this.getOrder(b, uri) - this.getOrder(a, uri)); + const defaultHandler = Object.entries(associations).find(([key]) => match(key, basename))?.[1] ?? handlers[0]?.id; + const items = this.getQuickPickItems(ordered, defaultHandler); + const result = await this.quickInputService.pick([...items, { + type: 'separator' + }, { + label: nls.localizeByDefault("Configure default editor for '{0}'...", ext) + }], { placeHolder: nls.localizeByDefault("Select editor for '{0}'", uri.path.base) }); if (result) { - return result.handler.open(uri); + if ('handler' in result) { + return result.handler.open(uri); + } else if (result.label) { + const configureResult = await this.quickInputService.pick(items, { + placeHolder: nls.localizeByDefault("Select new default editor for '{0}'", ext) + }); + if (configureResult) { + associations[ext] = configureResult.handler.id; + this.preferenceService.set('workbench.editorAssociations', associations, PreferenceScope.User); + return configureResult.handler.open(uri); + } + } } + return undefined; + } + + protected getQuickPickItems(handlers: OpenWithHandler[], defaultHandler?: string): OpenWithQuickPickItem[] { + return handlers.map(handler => ({ + handler, + label: handler.label ?? handler.id, + detail: handler.providerName ?? '', + description: handler.id === defaultHandler ? nls.localizeByDefault('Default') : undefined + })); + } + + protected getOrder(handler: OpenWithHandler, uri: URI): number { + return handler.getOrder ? handler.getOrder(uri) : handler.canHandle(uri); } getHandlers(uri: URI): OpenWithHandler[] { diff --git a/packages/core/src/browser/opener-service.ts b/packages/core/src/browser/opener-service.ts index d4f58f44a6fac..f8362fa4cc747 100644 --- a/packages/core/src/browser/opener-service.ts +++ b/packages/core/src/browser/opener-service.ts @@ -17,6 +17,8 @@ import { named, injectable, inject } from 'inversify'; import URI from '../common/uri'; import { ContributionProvider, Prioritizeable, MaybePromise, Emitter, Event, Disposable } from '../common'; +import { PreferenceService } from './preferences'; +import { match } from '../common/glob'; export interface OpenerOptions { } @@ -96,6 +98,17 @@ export async function open(openerService: OpenerService, uri: URI, options?: Ope return opener.open(uri, options); } +export function getDefaultHandler(uri: URI, preferenceService: PreferenceService): string | undefined { + const associations = preferenceService.get('workbench.editorAssociations', {}); + const defaultHandler = Object.entries(associations).find(([key]) => match(key, uri.path.base))?.[1]; + if (typeof defaultHandler === 'string') { + return defaultHandler; + } + return undefined; +} + +export const defaultHandlerPriority = 100_000; + @injectable() export class DefaultOpenerService implements OpenerService { // Collection of open-handlers for custom-editor contributions. diff --git a/packages/core/src/common/glob.ts b/packages/core/src/common/glob.ts index d32394ec90191..1676fc4234e45 100644 --- a/packages/core/src/common/glob.ts +++ b/packages/core/src/common/glob.ts @@ -454,10 +454,10 @@ function toRegExp(pattern: string): ParsedStringPattern { /** * Simplified glob matching. Supports a subset of glob patterns: - * - * matches anything inside a path segment - * - ? matches 1 character inside a path segment - * - ** matches anything including an empty path segment - * - simple brace expansion ({js,ts} => js or ts) + * - `*` matches anything inside a path segment + * - `?` matches 1 character inside a path segment + * - `**` matches anything including an empty path segment + * - simple brace expansion (`{js,ts}` => js or ts) * - character ranges (using [...]) */ export function match(pattern: string | IRelativePattern, path: string): boolean; diff --git a/packages/editor/src/browser/editor-manager.ts b/packages/editor/src/browser/editor-manager.ts index 50cf0d7d2bc03..bc19bb78f3917 100644 --- a/packages/editor/src/browser/editor-manager.ts +++ b/packages/editor/src/browser/editor-manager.ts @@ -17,7 +17,10 @@ import { injectable, postConstruct, inject } from '@theia/core/shared/inversify'; import URI from '@theia/core/lib/common/uri'; import { RecursivePartial, Emitter, Event, MaybePromise, CommandService, nls } from '@theia/core/lib/common'; -import { WidgetOpenerOptions, NavigatableWidgetOpenHandler, NavigatableWidgetOptions, Widget, PreferenceService, CommonCommands, OpenWithService } from '@theia/core/lib/browser'; +import { + WidgetOpenerOptions, NavigatableWidgetOpenHandler, NavigatableWidgetOptions, Widget, PreferenceService, CommonCommands, OpenWithService, getDefaultHandler, + defaultHandlerPriority +} from '@theia/core/lib/browser'; import { EditorWidget } from './editor-widget'; import { Range, Position, Location, TextEditor } from './editor'; import { EditorWidgetFactory } from './editor-widget-factory'; @@ -86,12 +89,13 @@ export class EditorManager extends NavigatableWidgetOpenHandler { } } this.openWithService.registerHandler({ - id: this.id, + id: 'default', label: this.label, providerName: nls.localizeByDefault('Built-in'), + canHandle: () => 100, // Higher priority than any other handler // so that the text editor always appears first in the quick pick - canHandle: uri => this.canHandle(uri) * 100, + getOrder: () => 10000, open: uri => this.open(uri) }); this.updateCurrentEditor(); @@ -198,6 +202,9 @@ export class EditorManager extends NavigatableWidgetOpenHandler { } canHandle(uri: URI, options?: WidgetOpenerOptions): number { + if (getDefaultHandler(uri, this.preferenceService) === 'default') { + return defaultHandlerPriority; + } return 100; } diff --git a/packages/notebook/src/browser/notebook-open-handler.ts b/packages/notebook/src/browser/notebook-open-handler.ts index d1f1ab307ef1a..80181f7a1a6fd 100644 --- a/packages/notebook/src/browser/notebook-open-handler.ts +++ b/packages/notebook/src/browser/notebook-open-handler.ts @@ -15,8 +15,8 @@ // ***************************************************************************** import { URI, MaybePromise, Disposable } from '@theia/core'; -import { NavigatableWidgetOpenHandler, WidgetOpenerOptions } from '@theia/core/lib/browser'; -import { injectable } from '@theia/core/shared/inversify'; +import { NavigatableWidgetOpenHandler, PreferenceService, WidgetOpenerOptions, getDefaultHandler, defaultHandlerPriority } from '@theia/core/lib/browser'; +import { inject, injectable } from '@theia/core/shared/inversify'; import { NotebookFileSelector, NotebookTypeDescriptor } from '../common/notebook-protocol'; import { NotebookEditorWidget } from './notebook-editor-widget'; import { match } from '@theia/core/lib/common/glob'; @@ -33,6 +33,9 @@ export class NotebookOpenHandler extends NavigatableWidgetOpenHandler { @@ -41,15 +44,16 @@ export class NotebookOpenHandler extends NavigatableWidgetOpenHandler { + const defaultHandler = getDefaultHandler(uri, this.preferenceService); if (options?.notebookType) { - return this.canHandleType(uri, this.notebookTypes.find(type => type.type === options.notebookType)); + return this.canHandleType(uri, this.notebookTypes.find(type => type.type === options.notebookType), defaultHandler); } - return Math.max(...this.notebookTypes.map(type => this.canHandleType(uri, type))); + return Math.max(...this.notebookTypes.map(type => this.canHandleType(uri, type), defaultHandler)); } - canHandleType(uri: URI, notebookType?: NotebookTypeDescriptor): number { + canHandleType(uri: URI, notebookType?: NotebookTypeDescriptor, defaultHandler?: string): number { if (notebookType?.selector && this.matches(notebookType.selector, uri)) { - return this.calculatePriority(notebookType); + return notebookType.type === defaultHandler ? defaultHandlerPriority : this.calculatePriority(notebookType); } else { return 0; } @@ -93,7 +97,9 @@ export class NotebookOpenHandler extends NavigatableWidgetOpenHandler type.type === defaultHandler) + || this.findHighestPriorityType(uri); if (!notebookType) { throw new Error('No notebook types registered for uri: ' + uri.toString()); } diff --git a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx index 3671fd12b2687..4c9746a9f5914 100644 --- a/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx +++ b/packages/plugin-ext/src/main/browser/custom-editors/custom-editor-opener.tsx @@ -15,7 +15,9 @@ // ***************************************************************************** import URI from '@theia/core/lib/common/uri'; -import { ApplicationShell, DiffUris, OpenHandler, SplitWidget, Widget, WidgetManager, WidgetOpenerOptions } from '@theia/core/lib/browser'; +import { + ApplicationShell, DiffUris, OpenHandler, OpenerOptions, PreferenceService, SplitWidget, Widget, WidgetManager, WidgetOpenerOptions, getDefaultHandler, defaultHandlerPriority +} from '@theia/core/lib/browser'; import { CustomEditor, CustomEditorPriority, CustomEditorSelector } from '../../../common'; import { CustomEditorWidget } from './custom-editor-widget'; import { PluginCustomEditorRegistry } from './plugin-custom-editor-registry'; @@ -35,7 +37,8 @@ export class CustomEditorOpener implements OpenHandler { private readonly editor: CustomEditor, protected readonly shell: ApplicationShell, protected readonly widgetManager: WidgetManager, - protected readonly editorRegistry: PluginCustomEditorRegistry + protected readonly editorRegistry: PluginCustomEditorRegistry, + protected readonly preferenceService: PreferenceService ) { this.id = CustomEditorOpener.toCustomEditorId(this.editor.viewType); this.label = this.editor.displayName; @@ -45,14 +48,26 @@ export class CustomEditorOpener implements OpenHandler { return `custom-editor-${editorViewType}`; } - canHandle(uri: URI): number { + canHandle(uri: URI, options?: OpenerOptions): number { + let priority = 0; const { selector } = this.editor; if (DiffUris.isDiffUri(uri)) { const [left, right] = DiffUris.decode(uri); if (this.matches(selector, right) && this.matches(selector, left)) { - return this.getPriority(); + priority = this.getPriority(); } } else if (this.matches(selector, uri)) { + if (getDefaultHandler(uri, this.preferenceService) === this.editor.viewType) { + priority = defaultHandlerPriority; + } else { + priority = this.getPriority(); + } + } + return priority; + } + + canOpenWith(uri: URI): number { + if (this.matches(this.editor.selector, uri)) { return this.getPriority(); } return 0; diff --git a/packages/plugin-ext/src/main/browser/custom-editors/plugin-custom-editor-registry.ts b/packages/plugin-ext/src/main/browser/custom-editors/plugin-custom-editor-registry.ts index 7250e3f4c0908..27738141688cf 100644 --- a/packages/plugin-ext/src/main/browser/custom-editors/plugin-custom-editor-registry.ts +++ b/packages/plugin-ext/src/main/browser/custom-editors/plugin-custom-editor-registry.ts @@ -20,7 +20,7 @@ import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposa import { Deferred } from '@theia/core/lib/common/promise-util'; import { CustomEditorOpener } from './custom-editor-opener'; import { Emitter } from '@theia/core'; -import { ApplicationShell, DefaultOpenerService, OpenWithService, WidgetManager } from '@theia/core/lib/browser'; +import { ApplicationShell, DefaultOpenerService, OpenWithService, PreferenceService, WidgetManager } from '@theia/core/lib/browser'; import { CustomEditorWidget } from './custom-editor-widget'; @injectable() @@ -44,6 +44,9 @@ export class PluginCustomEditorRegistry { @inject(OpenWithService) protected readonly openWithService: OpenWithService; + @inject(PreferenceService) + protected readonly preferenceService: PreferenceService; + @postConstruct() protected init(): void { this.widgetManager.onDidCreateWidget(({ factoryId, widget }) => { @@ -76,7 +79,8 @@ export class PluginCustomEditorRegistry { editor, this.shell, this.widgetManager, - this + this, + this.preferenceService ); toDispose.push(this.defaultOpenerService.addHandler(editorOpenHandler)); toDispose.push( @@ -84,7 +88,7 @@ export class PluginCustomEditorRegistry { id: editor.viewType, label: editorOpenHandler.label, providerName: plugin.metadata.model.displayName, - canHandle: uri => editorOpenHandler.canHandle(uri), + canHandle: uri => editorOpenHandler.canOpenWith(uri), open: uri => editorOpenHandler.open(uri) }) ); From 10eec0ceff0162106ebfc1c2a0b6d8b354026d2a Mon Sep 17 00:00:00 2001 From: Mark Sujew Date: Sat, 14 Sep 2024 10:41:49 +0200 Subject: [PATCH 2/2] Don't always show default editor selection --- packages/core/src/browser/open-with-service.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/core/src/browser/open-with-service.ts b/packages/core/src/browser/open-with-service.ts index 60218a955f599..1e347ca88301b 100644 --- a/packages/core/src/browser/open-with-service.ts +++ b/packages/core/src/browser/open-with-service.ts @@ -19,9 +19,9 @@ import { Disposable } from '../common/disposable'; import { nls } from '../common/nls'; import { MaybePromise } from '../common/types'; import { URI } from '../common/uri'; -import { match } from '../common/glob'; -import { QuickInputService, QuickPickItem } from './quick-input'; +import { QuickInputService, QuickPickItem, QuickPickItemOrSeparator } from './quick-input'; import { PreferenceScope, PreferenceService } from './preferences'; +import { getDefaultHandler } from './opener-service'; export interface OpenWithHandler { /** @@ -87,18 +87,20 @@ export class OpenWithService { } async openWith(uri: URI): Promise { - const associations: Record = { ...this.preferenceService.get('workbench.editorAssociations') }; - const basename = uri.path.base; + // Clone the object, because all objects returned by the preferences service are frozen. + const associations: Record = { ...this.preferenceService.get('workbench.editorAssociations') }; const ext = `*${uri.path.ext}`; const handlers = this.getHandlers(uri); const ordered = handlers.slice().sort((a, b) => this.getOrder(b, uri) - this.getOrder(a, uri)); - const defaultHandler = Object.entries(associations).find(([key]) => match(key, basename))?.[1] ?? handlers[0]?.id; + const defaultHandler = getDefaultHandler(uri, this.preferenceService) ?? handlers[0]?.id; const items = this.getQuickPickItems(ordered, defaultHandler); - const result = await this.quickInputService.pick([...items, { + // Only offer to select a default editor when the file has a file extension + const extraItems: QuickPickItemOrSeparator[] = uri.path.ext ? [{ type: 'separator' }, { label: nls.localizeByDefault("Configure default editor for '{0}'...", ext) - }], { + }] : []; + const result = await this.quickInputService.pick([...items, ...extraItems], { placeHolder: nls.localizeByDefault("Select editor for '{0}'", uri.path.base) }); if (result) {