Skip to content

Commit

Permalink
Support workbench.editorAssociations preference (#14139)
Browse files Browse the repository at this point in the history
  • Loading branch information
msujew authored Sep 16, 2024
1 parent beabfba commit 1364191
Show file tree
Hide file tree
Showing 8 changed files with 129 additions and 28 deletions.
9 changes: 9 additions & 0 deletions packages/core/src/browser/core-preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
}
}
};
Expand Down
61 changes: 54 additions & 7 deletions packages/core/src/browser/open-with-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { QuickInputService, QuickPickItem, QuickPickItemOrSeparator } from './quick-input';
import { PreferenceScope, PreferenceService } from './preferences';
import { getDefaultHandler } from './opener-service';

export interface OpenWithHandler {
/**
Expand All @@ -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.
Expand All @@ -54,12 +61,19 @@ export interface OpenWithHandler {
open(uri: URI): MaybePromise<object | undefined>;
}

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 {
Expand All @@ -73,17 +87,50 @@ export class OpenWithService {
}

async openWith(uri: URI): Promise<object | undefined> {
// Clone the object, because all objects returned by the preferences service are frozen.
const associations: Record<string, unknown> = { ...this.preferenceService.get('workbench.editorAssociations') };
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 = getDefaultHandler(uri, this.preferenceService) ?? handlers[0]?.id;
const items = this.getQuickPickItems(ordered, defaultHandler);
// 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<OpenWithQuickPickItem | { label: string }>([...items, ...extraItems], {
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[] {
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/browser/opener-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
}
Expand Down Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/common/glob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
13 changes: 10 additions & 3 deletions packages/editor/src/browser/editor-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -86,12 +89,13 @@ export class EditorManager extends NavigatableWidgetOpenHandler<EditorWidget> {
}
}
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();
Expand Down Expand Up @@ -198,6 +202,9 @@ export class EditorManager extends NavigatableWidgetOpenHandler<EditorWidget> {
}

canHandle(uri: URI, options?: WidgetOpenerOptions): number {
if (getDefaultHandler(uri, this.preferenceService) === 'default') {
return defaultHandlerPriority;
}
return 100;
}

Expand Down
20 changes: 13 additions & 7 deletions packages/notebook/src/browser/notebook-open-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,6 +33,9 @@ export class NotebookOpenHandler extends NavigatableWidgetOpenHandler<NotebookEd

protected notebookTypes: NotebookTypeDescriptor[] = [];

@inject(PreferenceService)
protected readonly preferenceService: PreferenceService;

registerNotebookType(notebookType: NotebookTypeDescriptor): Disposable {
this.notebookTypes.push(notebookType);
return Disposable.create(() => {
Expand All @@ -41,15 +44,16 @@ export class NotebookOpenHandler extends NavigatableWidgetOpenHandler<NotebookEd
}

canHandle(uri: URI, options?: NotebookWidgetOpenerOptions): MaybePromise<number> {
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;
}
Expand Down Expand Up @@ -93,7 +97,9 @@ export class NotebookOpenHandler extends NavigatableWidgetOpenHandler<NotebookEd
...widgetOptions
};
}
const notebookType = this.findHighestPriorityType(uri);
const defaultHandler = getDefaultHandler(uri, this.preferenceService);
const notebookType = this.notebookTypes.find(type => type.type === defaultHandler)
|| this.findHighestPriorityType(uri);
if (!notebookType) {
throw new Error('No notebook types registered for uri: ' + uri.toString());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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 }) => {
Expand Down Expand Up @@ -76,15 +79,16 @@ export class PluginCustomEditorRegistry {
editor,
this.shell,
this.widgetManager,
this
this,
this.preferenceService
);
toDispose.push(this.defaultOpenerService.addHandler(editorOpenHandler));
toDispose.push(
this.openWithService.registerHandler({
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)
})
);
Expand Down

0 comments on commit 1364191

Please sign in to comment.