Skip to content

Commit c0292b0

Browse files
committed
6636-custom-editor: Adds a prototype of custom editors contributed by 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>
1 parent 6798167 commit c0292b0

38 files changed

+3292
-68
lines changed

packages/core/src/browser/opener-service.spec.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
import { DefaultOpenerService, OpenHandler } from './opener-service';
1818
import * as assert from 'assert';
1919
import { MaybePromise } from '../common/types';
20+
import * as chai from 'chai';
21+
const expect = chai.expect;
2022

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

3638
describe('opener-service', () => {
37-
3839
it('getOpeners', () =>
3940
openerService.getOpeners().then(openers => {
4041
assert.deepStrictEqual([openHandler], openers);
4142
}));
43+
it('addHandler', () => {
44+
openerService.addHandler(openHandler);
45+
openerService.getOpeners().then(openers => {
46+
expect(openers.length).is.equal(2);
47+
});
48+
});
4249
});

packages/core/src/browser/opener-service.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616

1717
import { named, injectable, inject } from 'inversify';
1818
import URI from '../common/uri';
19-
import { ContributionProvider, Prioritizeable, MaybePromise } from '../common';
19+
import { ContributionProvider, Prioritizeable, MaybePromise, Emitter, Event, Disposable } from '../common';
2020

2121
export interface OpenerOptions {
2222
}
@@ -75,6 +75,14 @@ export interface OpenerService {
7575
* Reject if such does not exist.
7676
*/
7777
getOpener(uri: URI, options?: OpenerOptions): Promise<OpenHandler>;
78+
/**
79+
* Add open handler i.e. for custom editors
80+
*/
81+
addHandler?(openHandler: OpenHandler): Disposable;
82+
/**
83+
* Event that fires when a new opener is added or removed.
84+
*/
85+
onOpenersStateChanged?: Event<void>;
7886
}
7987

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

8593
@injectable()
8694
export class DefaultOpenerService implements OpenerService {
95+
// Collection of open-handlers for custom-editor contributions.
96+
protected readonly customEditorOpenHandlers: OpenHandler[] = [];
97+
98+
protected readonly onOpenersStateChangedEmitter = new Emitter<void>();
99+
readonly onOpenersStateChanged = this.onOpenersStateChangedEmitter.event;
87100

88101
constructor(
89102
@inject(ContributionProvider) @named(OpenHandler)
90103
protected readonly handlersProvider: ContributionProvider<OpenHandler>
91104
) { }
92105

106+
addHandler(openHandler: OpenHandler): Disposable {
107+
this.customEditorOpenHandlers.push(openHandler);
108+
this.onOpenersStateChangedEmitter.fire();
109+
110+
return Disposable.create(() => {
111+
this.customEditorOpenHandlers.splice(this.customEditorOpenHandlers.indexOf(openHandler), 1);
112+
this.onOpenersStateChangedEmitter.fire();
113+
});
114+
}
115+
93116
async getOpener(uri: URI, options?: OpenerOptions): Promise<OpenHandler> {
94117
const handlers = await this.prioritize(uri, options);
95118
if (handlers.length >= 1) {
@@ -114,7 +137,10 @@ export class DefaultOpenerService implements OpenerService {
114137
}
115138

116139
protected getHandlers(): OpenHandler[] {
117-
return this.handlersProvider.getContributions();
140+
return [
141+
...this.handlersProvider.getContributions(),
142+
...this.customEditorOpenHandlers
143+
];
118144
}
119145

120146
}

packages/core/src/browser/saveable.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export namespace Saveable {
5858
}
5959
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6060
export function isSource(arg: any): arg is SaveableSource {
61-
return !!arg && ('saveable' in arg);
61+
return !!arg && ('saveable' in arg) && is(arg.saveable);
6262
}
6363
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6464
export function is(arg: any): arg is Saveable {

packages/core/src/browser/test/mock-opener-service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@
1515
********************************************************************************/
1616

1717
import { injectable } from 'inversify';
18+
import { Disposable } from './../../common/disposable';
1819
import { OpenerService, OpenHandler } from '../opener-service';
1920

2021
/**
2122
* Mock opener service implementation for testing. Never provides handlers, but always rejects :)
2223
*/
2324
@injectable()
2425
export class MockOpenerService implements OpenerService {
26+
addHandler(openHandler: OpenHandler): Disposable {
27+
throw new Error('MockOpenerService is for testing only.');
28+
}
2529

2630
async getOpeners(): Promise<OpenHandler[]> {
2731
return [];

packages/editor/src/browser/editor.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@ import { Position, Range, Location } from 'vscode-languageserver-types';
1818
import * as lsp from 'vscode-languageserver-types';
1919
import URI from '@theia/core/lib/common/uri';
2020
import { Event, Disposable, TextDocumentContentChangeDelta } from '@theia/core/lib/common';
21-
import { Saveable, Navigatable } from '@theia/core/lib/browser';
21+
import { Saveable, Navigatable, Widget } from '@theia/core/lib/browser';
2222
import { EditorDecoration } from './decorations';
23+
import { Reference } from '@theia/core/lib/common';
2324

2425
export {
2526
Position, Range, Location
@@ -336,3 +337,14 @@ export namespace TextEditorSelection {
336337
return e && e['uri'] instanceof URI;
337338
}
338339
}
340+
341+
export namespace CustomEditorWidget {
342+
export function is(arg: Widget | undefined): arg is CustomEditorWidget {
343+
return !!arg && 'modelRef' in arg;
344+
}
345+
}
346+
347+
export interface CustomEditorWidget extends Widget {
348+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
349+
readonly modelRef: Reference<any>;
350+
}

packages/monaco/src/browser/monaco-command.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,9 @@ export class MonacoEditorCommandHandlers implements CommandContribution {
161161
},
162162
isEnabled: () => {
163163
const editor = codeEditorService.getFocusedCodeEditor() || codeEditorService.getActiveCodeEditor();
164+
if (!editor) {
165+
return false;
166+
}
164167
if (editorActions.has(id)) {
165168
const action = editor && editor.getAction(id);
166169
return !!action && action.isSupported();

packages/monaco/src/browser/monaco-editor-model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class MonacoEditorModel implements ITextEditorModel, TextEditorDocument {
4949

5050
autoSave: 'on' | 'off' = 'on';
5151
autoSaveDelay: number = 500;
52+
suppressOpenEditorWhenDirty = false;
5253
/* @deprecated there is no general save timeout, each participant should introduce a sensible timeout */
5354
readonly onWillSaveLoopTimeOut = 1500;
5455
protected bufferSavedVersionId: number;

packages/monaco/src/browser/monaco-editor-service.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
import { injectable, inject, decorate } from 'inversify';
1818
import URI from '@theia/core/lib/common/uri';
1919
import { OpenerService, open, WidgetOpenMode, ApplicationShell, PreferenceService } from '@theia/core/lib/browser';
20-
import { EditorWidget, EditorOpenerOptions, EditorManager } from '@theia/editor/lib/browser';
20+
import { EditorWidget, EditorOpenerOptions, EditorManager, CustomEditorWidget } from '@theia/editor/lib/browser';
2121
import { MonacoEditor } from './monaco-editor';
2222
import { MonacoToProtocolConverter } from './monaco-to-protocol-converter';
23+
import { MonacoEditorModel } from './monaco-editor-model';
2324

2425
import ICodeEditor = monaco.editor.ICodeEditor;
2526
import CommonCodeEditor = monaco.editor.CommonCodeEditor;
@@ -55,7 +56,13 @@ export class MonacoEditorService extends monaco.services.CodeEditorServiceImpl {
5556
* Monaco active editor is either focused or last focused editor.
5657
*/
5758
getActiveCodeEditor(): monaco.editor.IStandaloneCodeEditor | undefined {
58-
const editor = MonacoEditor.getCurrent(this.editors);
59+
let editor = MonacoEditor.getCurrent(this.editors);
60+
if (!editor && CustomEditorWidget.is(this.shell.activeWidget)) {
61+
const model = this.shell.activeWidget.modelRef.object;
62+
if (model.editorTextModel instanceof MonacoEditorModel) {
63+
editor = MonacoEditor.findByDocument(this.editors, model.editorTextModel)[0];
64+
}
65+
}
5966
return editor && editor.getControl();
6067
}
6168

packages/monaco/src/browser/monaco-workspace.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ export class MonacoWorkspace {
159159
protected readonly suppressedOpenIfDirty: MonacoEditorModel[] = [];
160160

161161
protected openEditorIfDirty(model: MonacoEditorModel): void {
162-
if (this.suppressedOpenIfDirty.indexOf(model) !== -1) {
162+
if (model.suppressOpenEditorWhenDirty || this.suppressedOpenIfDirty.indexOf(model) !== -1) {
163163
return;
164164
}
165165
if (model.dirty && MonacoEditor.findByDocument(this.editorManager, model).length === 0) {

packages/plugin-ext/src/common/plugin-api-rpc.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1440,6 +1440,40 @@ export interface WebviewsMain {
14401440
$unregisterSerializer(viewType: string): void;
14411441
}
14421442

1443+
export interface CustomEditorsExt {
1444+
$resolveWebviewEditor(
1445+
resource: UriComponents,
1446+
newWebviewHandle: string,
1447+
viewType: string,
1448+
title: string,
1449+
position: number,
1450+
options: theia.WebviewPanelOptions,
1451+
cancellation: CancellationToken): Promise<void>;
1452+
$createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken): Promise<{ editable: boolean }>;
1453+
$disposeCustomDocument(resource: UriComponents, viewType: string): Promise<void>;
1454+
$undo(resource: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void>;
1455+
$redo(resource: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void>;
1456+
$revert(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void>;
1457+
$disposeEdits(resourceComponents: UriComponents, viewType: string, editIds: number[]): void;
1458+
$onSave(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void>;
1459+
$onSaveAs(resource: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise<void>;
1460+
// $backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<string>;
1461+
$onMoveCustomEditor(handle: string, newResource: UriComponents, viewType: string): Promise<void>;
1462+
}
1463+
1464+
export interface CustomTextEditorCapabilities {
1465+
readonly supportsMove?: boolean;
1466+
}
1467+
1468+
export interface CustomEditorsMain {
1469+
$registerTextEditorProvider(viewType: string, options: theia.WebviewPanelOptions, capabilities: CustomTextEditorCapabilities): void;
1470+
$registerCustomEditorProvider(viewType: string, options: theia.WebviewPanelOptions, supportsMultipleEditorsPerDocument: boolean): void;
1471+
$unregisterEditorProvider(viewType: string): void;
1472+
$createCustomEditorPanel(handle: string, title: string, viewColumn: theia.ViewColumn | undefined, options: theia.WebviewPanelOptions & theia.WebviewOptions): Promise<void>;
1473+
$onDidEdit(resource: UriComponents, viewType: string, editId: number, label: string | undefined): void;
1474+
$onContentChange(resource: UriComponents, viewType: string): void;
1475+
}
1476+
14431477
export interface StorageMain {
14441478
$set(key: string, value: KeysToAnyValues, isGlobal: boolean): Promise<boolean>;
14451479
$get(key: string, isGlobal: boolean): Promise<KeysToAnyValues>;
@@ -1578,6 +1612,7 @@ export const PLUGIN_RPC_CONTEXT = {
15781612
LANGUAGES_MAIN: createProxyIdentifier<LanguagesMain>('LanguagesMain'),
15791613
CONNECTION_MAIN: createProxyIdentifier<ConnectionMain>('ConnectionMain'),
15801614
WEBVIEWS_MAIN: createProxyIdentifier<WebviewsMain>('WebviewsMain'),
1615+
CUSTOM_EDITORS_MAIN: createProxyIdentifier<CustomEditorsMain>('CustomEditorsMain'),
15811616
STORAGE_MAIN: createProxyIdentifier<StorageMain>('StorageMain'),
15821617
TASKS_MAIN: createProxyIdentifier<TasksMain>('TasksMain'),
15831618
DEBUG_MAIN: createProxyIdentifier<DebugMain>('DebugMain'),
@@ -1610,6 +1645,7 @@ export const MAIN_RPC_CONTEXT = {
16101645
LANGUAGES_EXT: createProxyIdentifier<LanguagesExt>('LanguagesExt'),
16111646
CONNECTION_EXT: createProxyIdentifier<ConnectionExt>('ConnectionExt'),
16121647
WEBVIEWS_EXT: createProxyIdentifier<WebviewsExt>('WebviewsExt'),
1648+
CUSTOM_EDITORS_EXT: createProxyIdentifier<CustomEditorsExt>('CustomEditorsExt'),
16131649
STORAGE_EXT: createProxyIdentifier<StorageExt>('StorageExt'),
16141650
TASKS_EXT: createProxyIdentifier<TasksExt>('TasksExt'),
16151651
DEBUG_EXT: createProxyIdentifier<DebugExt>('DebugExt'),
@@ -1620,7 +1656,8 @@ export const MAIN_RPC_CONTEXT = {
16201656
LABEL_SERVICE_EXT: createProxyIdentifier<LabelServiceExt>('LabelServiceExt'),
16211657
TIMELINE_EXT: createProxyIdentifier<TimelineExt>('TimeLineExt'),
16221658
THEMING_EXT: createProxyIdentifier<ThemingExt>('ThemingExt'),
1623-
COMMENTS_EXT: createProxyIdentifier<CommentsExt>('CommentsExt')};
1659+
COMMENTS_EXT: createProxyIdentifier<CommentsExt>('CommentsExt')
1660+
};
16241661

16251662
export interface TasksExt {
16261663
$provideTasks(handle: number, token?: CancellationToken): Promise<TaskDto[] | undefined>;

0 commit comments

Comments
 (0)