Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support workbench.editorAssociations preference #14139

Merged
merged 2 commits into from
Sep 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading