Skip to content

Commit

Permalink
6636-custom-editor: Adds a prototype of custom editors contributed by…
Browse files Browse the repository at this point in the history
… extensions with this functionality:

- Adds a new contribution point for custom editors.
- Adds API for registering a custom editor providers.
- Implements CustomEditor extension API - based on VSCode (excluding backup functionality not implemented in this PR).
- Adds CustomEditorWidget extending WebviewWidget containing a model reference to CustomEditorModel.
- Supports two CustomEditorModel implementations: CustomTextEditorModel for text documents and MainCustomEditorModel for binary documents.
- Registers openHandlers for CustomEditors.
- Adds `openWith` command for selecting which editor to use when openning a resource.

Signed-off-by: Dan Arad <dan.arad@sap.com>
  • Loading branch information
danarad05 committed Mar 4, 2021
1 parent 6798167 commit aedcc46
Show file tree
Hide file tree
Showing 37 changed files with 3,287 additions and 68 deletions.
9 changes: 8 additions & 1 deletion packages/core/src/browser/opener-service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
import { DefaultOpenerService, OpenHandler } from './opener-service';
import * as assert from 'assert';
import { MaybePromise } from '../common/types';
import * as chai from 'chai';
const expect = chai.expect;

const id = 'my-opener';
const openHandler: OpenHandler = {
Expand All @@ -34,9 +36,14 @@ const openerService = new DefaultOpenerService({
});

describe('opener-service', () => {

it('getOpeners', () =>
openerService.getOpeners().then(openers => {
assert.deepStrictEqual([openHandler], openers);
}));
it('addHandler', () => {
openerService.addHandler(openHandler);
openerService.getOpeners().then(openers => {
expect(openers.length).is.equal(2);
});
});
});
30 changes: 28 additions & 2 deletions packages/core/src/browser/opener-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

import { named, injectable, inject } from 'inversify';
import URI from '../common/uri';
import { ContributionProvider, Prioritizeable, MaybePromise } from '../common';
import { ContributionProvider, Prioritizeable, MaybePromise, Emitter, Event, Disposable } from '../common';

export interface OpenerOptions {
}
Expand Down Expand Up @@ -75,6 +75,14 @@ export interface OpenerService {
* Reject if such does not exist.
*/
getOpener(uri: URI, options?: OpenerOptions): Promise<OpenHandler>;
/**
* Add open handler i.e. for custom editors
*/
addHandler?(openHandler: OpenHandler): Disposable;
/**
* Event that fires when a new opener is added or removed.
*/
onDidChangeOpeners?: Event<void>;
}

export async function open(openerService: OpenerService, uri: URI, options?: OpenerOptions): Promise<object | undefined> {
Expand All @@ -84,12 +92,27 @@ export async function open(openerService: OpenerService, uri: URI, options?: Ope

@injectable()
export class DefaultOpenerService implements OpenerService {
// Collection of open-handlers for custom-editor contributions.
protected readonly customEditorOpenHandlers: OpenHandler[] = [];

protected readonly onDidChangeOpenersEmitter = new Emitter<void>();
readonly onDidChangeOpeners = this.onDidChangeOpenersEmitter.event;

constructor(
@inject(ContributionProvider) @named(OpenHandler)
protected readonly handlersProvider: ContributionProvider<OpenHandler>
) { }

addHandler(openHandler: OpenHandler): Disposable {
this.customEditorOpenHandlers.push(openHandler);
this.onDidChangeOpenersEmitter.fire();

return Disposable.create(() => {
this.customEditorOpenHandlers.splice(this.customEditorOpenHandlers.indexOf(openHandler), 1);
this.onDidChangeOpenersEmitter.fire();
});
}

async getOpener(uri: URI, options?: OpenerOptions): Promise<OpenHandler> {
const handlers = await this.prioritize(uri, options);
if (handlers.length >= 1) {
Expand All @@ -114,7 +137,10 @@ export class DefaultOpenerService implements OpenerService {
}

protected getHandlers(): OpenHandler[] {
return this.handlersProvider.getContributions();
return [
...this.handlersProvider.getContributions(),
...this.customEditorOpenHandlers
];
}

}
2 changes: 1 addition & 1 deletion packages/core/src/browser/saveable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export namespace Saveable {
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isSource(arg: any): arg is SaveableSource {
return !!arg && ('saveable' in arg);
return !!arg && ('saveable' in arg) && is(arg.saveable);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function is(arg: any): arg is Saveable {
Expand Down
14 changes: 13 additions & 1 deletion packages/editor/src/browser/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ import { Position, Range, Location } from 'vscode-languageserver-types';
import * as lsp from 'vscode-languageserver-types';
import URI from '@theia/core/lib/common/uri';
import { Event, Disposable, TextDocumentContentChangeDelta } from '@theia/core/lib/common';
import { Saveable, Navigatable } from '@theia/core/lib/browser';
import { Saveable, Navigatable, Widget } from '@theia/core/lib/browser';
import { EditorDecoration } from './decorations';
import { Reference } from '@theia/core/lib/common';

export {
Position, Range, Location
Expand Down Expand Up @@ -336,3 +337,14 @@ export namespace TextEditorSelection {
return e && e['uri'] instanceof URI;
}
}

export namespace CustomEditorWidget {
export function is(arg: Widget | undefined): arg is CustomEditorWidget {
return !!arg && 'modelRef' in arg;
}
}

export interface CustomEditorWidget extends Widget {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly modelRef: Reference<any>;
}
3 changes: 3 additions & 0 deletions packages/monaco/src/browser/monaco-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ export class MonacoEditorCommandHandlers implements CommandContribution {
},
isEnabled: () => {
const editor = codeEditorService.getFocusedCodeEditor() || codeEditorService.getActiveCodeEditor();
if (!editor) {
return false;
}
if (editorActions.has(id)) {
const action = editor && editor.getAction(id);
return !!action && action.isSupported();
Expand Down
1 change: 1 addition & 0 deletions packages/monaco/src/browser/monaco-editor-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument {

autoSave: 'on' | 'off' = 'on';
autoSaveDelay: number = 500;
suppressOpenEditorWhenDirty = false;
/* @deprecated there is no general save timeout, each participant should introduce a sensible timeout */
readonly onWillSaveLoopTimeOut = 1500;
protected bufferSavedVersionId: number;
Expand Down
11 changes: 9 additions & 2 deletions packages/monaco/src/browser/monaco-editor-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
import { injectable, inject, decorate } from 'inversify';
import URI from '@theia/core/lib/common/uri';
import { OpenerService, open, WidgetOpenMode, ApplicationShell, PreferenceService } from '@theia/core/lib/browser';
import { EditorWidget, EditorOpenerOptions, EditorManager } from '@theia/editor/lib/browser';
import { EditorWidget, EditorOpenerOptions, EditorManager, CustomEditorWidget } from '@theia/editor/lib/browser';
import { MonacoEditor } from './monaco-editor';
import { MonacoToProtocolConverter } from './monaco-to-protocol-converter';
import { MonacoEditorModel } from './monaco-editor-model';

import ICodeEditor = monaco.editor.ICodeEditor;
import CommonCodeEditor = monaco.editor.CommonCodeEditor;
Expand Down Expand Up @@ -55,7 +56,13 @@ export class MonacoEditorService extends monaco.services.CodeEditorServiceImpl {
* Monaco active editor is either focused or last focused editor.
*/
getActiveCodeEditor(): monaco.editor.IStandaloneCodeEditor | undefined {
const editor = MonacoEditor.getCurrent(this.editors);
let editor = MonacoEditor.getCurrent(this.editors);
if (!editor && CustomEditorWidget.is(this.shell.activeWidget)) {
const model = this.shell.activeWidget.modelRef.object;
if (model.editorTextModel instanceof MonacoEditorModel) {
editor = MonacoEditor.findByDocument(this.editors, model.editorTextModel)[0];
}
}
return editor && editor.getControl();
}

Expand Down
2 changes: 1 addition & 1 deletion packages/monaco/src/browser/monaco-workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ export class MonacoWorkspace {
protected readonly suppressedOpenIfDirty: MonacoEditorModel[] = [];

protected openEditorIfDirty(model: MonacoEditorModel): void {
if (this.suppressedOpenIfDirty.indexOf(model) !== -1) {
if (model.suppressOpenEditorWhenDirty || this.suppressedOpenIfDirty.indexOf(model) !== -1) {
return;
}
if (model.dirty && MonacoEditor.findByDocument(this.editorManager, model).length === 0) {
Expand Down
39 changes: 38 additions & 1 deletion packages/plugin-ext/src/common/plugin-api-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,40 @@ export interface WebviewsMain {
$unregisterSerializer(viewType: string): void;
}

export interface CustomEditorsExt {
$resolveWebviewEditor(
resource: UriComponents,
newWebviewHandle: string,
viewType: string,
title: string,
position: number,
options: theia.WebviewPanelOptions,
cancellation: CancellationToken): Promise<void>;
$createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken): Promise<{ editable: boolean }>;
$disposeCustomDocument(resource: UriComponents, viewType: string): Promise<void>;
$undo(resource: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void>;
$redo(resource: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void>;
$revert(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void>;
$disposeEdits(resourceComponents: UriComponents, viewType: string, editIds: number[]): void;
$onSave(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void>;
$onSaveAs(resource: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise<void>;
// $backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<string>;
$onMoveCustomEditor(handle: string, newResource: UriComponents, viewType: string): Promise<void>;
}

export interface CustomTextEditorCapabilities {
readonly supportsMove?: boolean;
}

export interface CustomEditorsMain {
$registerTextEditorProvider(viewType: string, options: theia.WebviewPanelOptions, capabilities: CustomTextEditorCapabilities): void;
$registerCustomEditorProvider(viewType: string, options: theia.WebviewPanelOptions, supportsMultipleEditorsPerDocument: boolean): void;
$unregisterEditorProvider(viewType: string): void;
$createCustomEditorPanel(handle: string, title: string, viewColumn: theia.ViewColumn | undefined, options: theia.WebviewPanelOptions & theia.WebviewOptions): Promise<void>;
$onDidEdit(resource: UriComponents, viewType: string, editId: number, label: string | undefined): void;
$onContentChange(resource: UriComponents, viewType: string): void;
}

export interface StorageMain {
$set(key: string, value: KeysToAnyValues, isGlobal: boolean): Promise<boolean>;
$get(key: string, isGlobal: boolean): Promise<KeysToAnyValues>;
Expand Down Expand Up @@ -1578,6 +1612,7 @@ export const PLUGIN_RPC_CONTEXT = {
LANGUAGES_MAIN: createProxyIdentifier<LanguagesMain>('LanguagesMain'),
CONNECTION_MAIN: createProxyIdentifier<ConnectionMain>('ConnectionMain'),
WEBVIEWS_MAIN: createProxyIdentifier<WebviewsMain>('WebviewsMain'),
CUSTOM_EDITORS_MAIN: createProxyIdentifier<CustomEditorsMain>('CustomEditorsMain'),
STORAGE_MAIN: createProxyIdentifier<StorageMain>('StorageMain'),
TASKS_MAIN: createProxyIdentifier<TasksMain>('TasksMain'),
DEBUG_MAIN: createProxyIdentifier<DebugMain>('DebugMain'),
Expand Down Expand Up @@ -1610,6 +1645,7 @@ export const MAIN_RPC_CONTEXT = {
LANGUAGES_EXT: createProxyIdentifier<LanguagesExt>('LanguagesExt'),
CONNECTION_EXT: createProxyIdentifier<ConnectionExt>('ConnectionExt'),
WEBVIEWS_EXT: createProxyIdentifier<WebviewsExt>('WebviewsExt'),
CUSTOM_EDITORS_EXT: createProxyIdentifier<CustomEditorsExt>('CustomEditorsExt'),
STORAGE_EXT: createProxyIdentifier<StorageExt>('StorageExt'),
TASKS_EXT: createProxyIdentifier<TasksExt>('TasksExt'),
DEBUG_EXT: createProxyIdentifier<DebugExt>('DebugExt'),
Expand All @@ -1620,7 +1656,8 @@ export const MAIN_RPC_CONTEXT = {
LABEL_SERVICE_EXT: createProxyIdentifier<LabelServiceExt>('LabelServiceExt'),
TIMELINE_EXT: createProxyIdentifier<TimelineExt>('TimeLineExt'),
THEMING_EXT: createProxyIdentifier<ThemingExt>('ThemingExt'),
COMMENTS_EXT: createProxyIdentifier<CommentsExt>('CommentsExt')};
COMMENTS_EXT: createProxyIdentifier<CommentsExt>('CommentsExt')
};

export interface TasksExt {
$provideTasks(handle: number, token?: CancellationToken): Promise<TaskDto[] | undefined>;
Expand Down
29 changes: 29 additions & 0 deletions packages/plugin-ext/src/common/plugin-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface PluginPackageContribution {
configurationDefaults?: RecursivePartial<PreferenceSchemaProperties>;
languages?: PluginPackageLanguageContribution[];
grammars?: PluginPackageGrammarsContribution[];
customEditors?: PluginPackageCustomEditor[];
viewsContainers?: { [location: string]: PluginPackageViewContainer[] };
views?: { [location: string]: PluginPackageView[] };
viewsWelcome?: PluginPackageViewWelcome[];
Expand All @@ -90,6 +91,23 @@ export interface PluginPackageContribution {
resourceLabelFormatters?: ResourceLabelFormatter[];
}

export interface PluginPackageCustomEditor {
viewType: string;
displayName: string;
selector?: CustomEditorSelector[];
priority?: CustomEditorPriority;
}

export interface CustomEditorSelector {
readonly filenamePattern?: string;
}

export enum CustomEditorPriority {
default = 'default',
builtin = 'builtin',
option = 'option',
}

export interface PluginPackageViewContainer {
id: string;
title: string;
Expand Down Expand Up @@ -489,6 +507,7 @@ export interface PluginContribution {
configurationDefaults?: PreferenceSchemaProperties;
languages?: LanguageContribution[];
grammars?: GrammarsContribution[];
customEditors?: CustomEditor[];
viewsContainers?: { [location: string]: ViewContainer[] };
views?: { [location: string]: View[] };
viewsWelcome?: ViewWelcome[];
Expand Down Expand Up @@ -612,6 +631,16 @@ export interface FoldingRules {
markers?: FoldingMarkers;
}

/**
* Custom Editors contribution
*/
export interface CustomEditor {
viewType: string;
displayName: string;
selector: CustomEditorSelector[];
priority: CustomEditorPriority;
}

/**
* Views Containers contribution
*/
Expand Down
21 changes: 19 additions & 2 deletions packages/plugin-ext/src/hosted/browser/hosted-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/front
import { environment } from '@theia/application-package/lib/environment';
import { JsonSchemaStore } from '@theia/core/lib/browser/json-schema-store';
import { FileService, FileSystemProviderActivationEvent } from '@theia/filesystem/lib/browser/file-service';
import { PluginCustomEditorRegistry } from '../../main/browser/custom-editors/plugin-custom-editor-registry';
import { CustomEditorWidget } from '../../main/browser/custom-editors/custom-editor-widget';

export type PluginHost = 'frontend' | string;
export type DebugActivationEvent = 'onDebugResolve' | 'onDebugInitialConfigurations' | 'onDebugAdapterProtocolTracker';
Expand Down Expand Up @@ -151,6 +153,9 @@ export class HostedPluginSupport {
@inject(JsonSchemaStore)
protected readonly jsonSchemaStore: JsonSchemaStore;

@inject(PluginCustomEditorRegistry)
protected readonly customEditorRegistry: PluginCustomEditorRegistry;

private theiaReadyPromise: Promise<any>;

protected readonly managers = new Map<string, PluginManagerExt>();
Expand Down Expand Up @@ -197,9 +202,10 @@ export class HostedPluginSupport {
this.taskProviderRegistry.onWillProvideTaskProvider(event => this.ensureTaskActivation(event));
this.taskResolverRegistry.onWillProvideTaskResolver(event => this.ensureTaskActivation(event));
this.fileService.onWillActivateFileSystemProvider(event => this.ensureFileSystemActivation(event));
this.customEditorRegistry.onWillOpenCustomEditor(event => this.activateByCustomEditor(event));

this.widgets.onDidCreateWidget(({ factoryId, widget }) => {
if (factoryId === WebviewWidget.FACTORY_ID && widget instanceof WebviewWidget) {
if ((factoryId === WebviewWidget.FACTORY_ID || factoryId === CustomEditorWidget.FACTORY_ID) && widget instanceof WebviewWidget) {
const storeState = widget.storeState.bind(widget);
const restoreState = widget.restoreState.bind(widget);

Expand Down Expand Up @@ -556,6 +562,10 @@ export class HostedPluginSupport {
await this.activateByEvent(`onCommand:${commandId}`);
}

async activateByCustomEditor(viewType: string): Promise<void> {
await this.activateByEvent(`onCustomEditor:${viewType}`);
}

activateByFileSystem(event: FileSystemProviderActivationEvent): Promise<void> {
return this.activateByEvent(`onFileSystem:${event.scheme}`);
}
Expand Down Expand Up @@ -713,10 +723,17 @@ export class HostedPluginSupport {
this.webviewRevivers.delete(viewType);
}

protected preserveWebviews(): void {
protected async preserveWebviews(): Promise<void> {
for (const webview of this.widgets.getWidgets(WebviewWidget.FACTORY_ID)) {
this.preserveWebview(webview as WebviewWidget);
}
for (const webview of this.widgets.getWidgets(CustomEditorWidget.FACTORY_ID)) {
(webview as CustomEditorWidget).modelRef.dispose();
if ((webview as any)['closeWithoutSaving']) {
delete (webview as any)['closeWithoutSaving'];
}
this.customEditorRegistry.resolveWidget(webview as CustomEditorWidget);
}
}

protected preserveWebview(webview: WebviewWidget): void {
Expand Down
Loading

0 comments on commit aedcc46

Please sign in to comment.