Skip to content

Commit 3639693

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. - Adds Undo/Redo functionality for CustomEditors. Signed-off-by: Dan Arad <dan.arad@sap.com>
1 parent 4a3e133 commit 3639693

35 files changed

+3281
-68
lines changed

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

Lines changed: 24 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,10 @@ export interface OpenerService {
7575
* Reject if such does not exist.
7676
*/
7777
getOpener(uri: URI, options?: OpenerOptions): Promise<OpenHandler>;
78+
/**
79+
* Event that fires when a new opener is added or removed.
80+
*/
81+
onOpenersStateChanged?: Event<void>;
7882
}
7983

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

8589
@injectable()
8690
export class DefaultOpenerService implements OpenerService {
91+
// Collection of open-handlers for custom-editor contributions.
92+
protected readonly customEditorOpenHandlers: OpenHandler[] = [];
93+
94+
protected readonly onOpenersStateChangedEmitter = new Emitter<void>();
95+
readonly onOpenersStateChanged = this.onOpenersStateChangedEmitter.event;
8796

8897
constructor(
8998
@inject(ContributionProvider) @named(OpenHandler)
9099
protected readonly handlersProvider: ContributionProvider<OpenHandler>
91100
) { }
92101

102+
public addHandler(openHandler: OpenHandler): Disposable {
103+
this.customEditorOpenHandlers.push(openHandler);
104+
this.onOpenersStateChangedEmitter.fire();
105+
106+
return Disposable.create(() => {
107+
this.customEditorOpenHandlers.splice(this.customEditorOpenHandlers.indexOf(openHandler), 1);
108+
this.onOpenersStateChangedEmitter.fire();
109+
});
110+
}
111+
93112
async getOpener(uri: URI, options?: OpenerOptions): Promise<OpenHandler> {
94113
const handlers = await this.prioritize(uri, options);
95114
if (handlers.length >= 1) {
@@ -114,7 +133,10 @@ export class DefaultOpenerService implements OpenerService {
114133
}
115134

116135
protected getHandlers(): OpenHandler[] {
117-
return this.handlersProvider.getContributions();
136+
return [
137+
...this.handlersProvider.getContributions(),
138+
...this.customEditorOpenHandlers
139+
];
118140
}
119141

120142
}

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/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-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: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@ import { injectable, inject, postConstruct } from 'inversify';
2121
import URI from '@theia/core/lib/common/uri';
2222
import { Emitter } from '@theia/core/lib/common/event';
2323
import { FileSystemPreferences } from '@theia/filesystem/lib/browser';
24-
import { EditorManager } from '@theia/editor/lib/browser';
24+
import { CustomEditorWidget, EditorManager } from '@theia/editor/lib/browser';
2525
import { MonacoTextModelService } from './monaco-text-model-service';
2626
import { WillSaveMonacoModelEvent, MonacoEditorModel, MonacoModelContentChangedEvent } from './monaco-editor-model';
2727
import { MonacoEditor } from './monaco-editor';
2828
import { ProblemManager } from '@theia/markers/lib/browser';
2929
import { MaybePromise } from '@theia/core/lib/common/types';
3030
import { FileService } from '@theia/filesystem/lib/browser/file-service';
3131
import { FileSystemProviderCapabilities } from '@theia/filesystem/lib/common/files';
32+
import { ApplicationShell } from '@theia/core/lib/browser';
3233

3334
export namespace WorkspaceFileEdit {
3435
export function is(arg: Edit): arg is monaco.languages.WorkspaceFileEdit {
@@ -99,6 +100,9 @@ export class MonacoWorkspace {
99100
@inject(ProblemManager)
100101
protected readonly problems: ProblemManager;
101102

103+
@inject(ApplicationShell)
104+
protected readonly shell: ApplicationShell;
105+
102106
@postConstruct()
103107
protected init(): void {
104108
this.resolveReady();
@@ -162,7 +166,7 @@ export class MonacoWorkspace {
162166
if (this.suppressedOpenIfDirty.indexOf(model) !== -1) {
163167
return;
164168
}
165-
if (model.dirty && MonacoEditor.findByDocument(this.editorManager, model).length === 0) {
169+
if (model.dirty && MonacoEditor.findByDocument(this.editorManager, model).length === 0 && !CustomEditorWidget.is(this.shell.activeWidget)) {
166170
// create a new reference to make sure the model is not disposed before it is
167171
// acquired by the editor, thus losing the changes that made it dirty.
168172
this.textModelService.createModelReference(model.textEditorModel.uri).then(ref => {

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

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

1427+
export interface CustomEditorsExt {
1428+
$resolveWebviewEditor(
1429+
resource: UriComponents,
1430+
newWebviewHandle: string,
1431+
viewType: string,
1432+
title: string,
1433+
position: number,
1434+
options: theia.WebviewPanelOptions,
1435+
cancellation: CancellationToken): Promise<void>;
1436+
$createCustomDocument(resource: UriComponents, viewType: string, backupId: string | undefined, cancellation: CancellationToken): Promise<{ editable: boolean }>;
1437+
$disposeCustomDocument(resource: UriComponents, viewType: string): Promise<void>;
1438+
$undo(resource: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void>;
1439+
$redo(resource: UriComponents, viewType: string, editId: number, isDirty: boolean): Promise<void>;
1440+
$revert(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void>;
1441+
$disposeEdits(resourceComponents: UriComponents, viewType: string, editIds: number[]): void;
1442+
$onSave(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<void>;
1443+
$onSaveAs(resource: UriComponents, viewType: string, targetResource: UriComponents, cancellation: CancellationToken): Promise<void>;
1444+
// $backup(resource: UriComponents, viewType: string, cancellation: CancellationToken): Promise<string>;
1445+
$onMoveCustomEditor(handle: string, newResource: UriComponents, viewType: string): Promise<void>;
1446+
}
1447+
1448+
export interface CustomTextEditorCapabilities {
1449+
readonly supportsMove?: boolean;
1450+
}
1451+
1452+
export interface CustomEditorsMain {
1453+
$registerTextEditorProvider(viewType: string, options: theia.WebviewPanelOptions, capabilities: CustomTextEditorCapabilities): void;
1454+
$registerCustomEditorProvider(viewType: string, options: theia.WebviewPanelOptions, supportsMultipleEditorsPerDocument: boolean): void;
1455+
$unregisterEditorProvider(viewType: string): void;
1456+
$createCustomEditorPanel(handle: string, title: string, viewColumn: theia.ViewColumn | undefined, options: theia.WebviewPanelOptions & theia.WebviewOptions): Promise<void>;
1457+
$onDidEdit(resource: UriComponents, viewType: string, editId: number, label: string | undefined): void;
1458+
$onContentChange(resource: UriComponents, viewType: string): void;
1459+
}
1460+
14271461
export interface StorageMain {
14281462
$set(key: string, value: KeysToAnyValues, isGlobal: boolean): Promise<boolean>;
14291463
$get(key: string, isGlobal: boolean): Promise<KeysToAnyValues>;
@@ -1562,6 +1596,7 @@ export const PLUGIN_RPC_CONTEXT = {
15621596
LANGUAGES_MAIN: createProxyIdentifier<LanguagesMain>('LanguagesMain'),
15631597
CONNECTION_MAIN: createProxyIdentifier<ConnectionMain>('ConnectionMain'),
15641598
WEBVIEWS_MAIN: createProxyIdentifier<WebviewsMain>('WebviewsMain'),
1599+
CUSTOM_EDITORS_MAIN: createProxyIdentifier<CustomEditorsMain>('CustomEditorsMain'),
15651600
STORAGE_MAIN: createProxyIdentifier<StorageMain>('StorageMain'),
15661601
TASKS_MAIN: createProxyIdentifier<TasksMain>('TasksMain'),
15671602
DEBUG_MAIN: createProxyIdentifier<DebugMain>('DebugMain'),
@@ -1594,6 +1629,7 @@ export const MAIN_RPC_CONTEXT = {
15941629
LANGUAGES_EXT: createProxyIdentifier<LanguagesExt>('LanguagesExt'),
15951630
CONNECTION_EXT: createProxyIdentifier<ConnectionExt>('ConnectionExt'),
15961631
WEBVIEWS_EXT: createProxyIdentifier<WebviewsExt>('WebviewsExt'),
1632+
CUSTOM_EDITORS_EXT: createProxyIdentifier<CustomEditorsExt>('CustomEditorsExt'),
15971633
STORAGE_EXT: createProxyIdentifier<StorageExt>('StorageExt'),
15981634
TASKS_EXT: createProxyIdentifier<TasksExt>('TasksExt'),
15991635
DEBUG_EXT: createProxyIdentifier<DebugExt>('DebugExt'),
@@ -1604,7 +1640,8 @@ export const MAIN_RPC_CONTEXT = {
16041640
LABEL_SERVICE_EXT: createProxyIdentifier<LabelServiceExt>('LabelServiceExt'),
16051641
TIMELINE_EXT: createProxyIdentifier<TimelineExt>('TimeLineExt'),
16061642
THEMING_EXT: createProxyIdentifier<ThemingExt>('ThemingExt'),
1607-
COMMENTS_EXT: createProxyIdentifier<CommentsExt>('CommentsExt')};
1643+
COMMENTS_EXT: createProxyIdentifier<CommentsExt>('CommentsExt')
1644+
};
16081645

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

packages/plugin-ext/src/common/plugin-protocol.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface PluginPackageContribution {
7171
configurationDefaults?: RecursivePartial<PreferenceSchemaProperties>;
7272
languages?: PluginPackageLanguageContribution[];
7373
grammars?: PluginPackageGrammarsContribution[];
74+
customEditors?: PluginPackageCustomEditor[];
7475
viewsContainers?: { [location: string]: PluginPackageViewContainer[] };
7576
views?: { [location: string]: PluginPackageView[] };
7677
viewsWelcome?: PluginPackageViewWelcome[];
@@ -89,6 +90,23 @@ export interface PluginPackageContribution {
8990
resourceLabelFormatters?: ResourceLabelFormatter[];
9091
}
9192

93+
export interface PluginPackageCustomEditor {
94+
viewType: string;
95+
displayName: string;
96+
selector?: CustomEditorSelector[];
97+
priority?: CustomEditorPriority;
98+
}
99+
100+
export interface CustomEditorSelector {
101+
readonly filenamePattern?: string;
102+
}
103+
104+
export enum CustomEditorPriority {
105+
default = 'default',
106+
builtin = 'builtin',
107+
option = 'option',
108+
}
109+
92110
export interface PluginPackageViewContainer {
93111
id: string;
94112
title: string;
@@ -482,6 +500,7 @@ export interface PluginContribution {
482500
configurationDefaults?: PreferenceSchemaProperties;
483501
languages?: LanguageContribution[];
484502
grammars?: GrammarsContribution[];
503+
customEditors?: CustomEditor[];
485504
viewsContainers?: { [location: string]: ViewContainer[] };
486505
views?: { [location: string]: View[] };
487506
viewsWelcome?: ViewWelcome[];
@@ -604,6 +623,16 @@ export interface FoldingRules {
604623
markers?: FoldingMarkers;
605624
}
606625

626+
/**
627+
* Custom Editors contribution
628+
*/
629+
export interface CustomEditor {
630+
viewType: string;
631+
displayName: string;
632+
selector: CustomEditorSelector[];
633+
priority: CustomEditorPriority;
634+
}
635+
607636
/**
608637
* Views Containers contribution
609638
*/

packages/plugin-ext/src/hosted/browser/hosted-plugin.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/front
6262
import { environment } from '@theia/application-package/lib/environment';
6363
import { JsonSchemaStore } from '@theia/core/lib/browser/json-schema-store';
6464
import { FileService, FileSystemProviderActivationEvent } from '@theia/filesystem/lib/browser/file-service';
65+
import { PluginCustomEditorRegistry } from '../../main/browser/custom-editors/plugin-custom-editor-registry';
66+
import { CustomEditorWidget } from '../../main/browser/custom-editors/custom-editor-widget';
6567

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

156+
@inject(PluginCustomEditorRegistry)
157+
protected readonly customEditorRegistry: PluginCustomEditorRegistry;
158+
154159
private theiaReadyPromise: Promise<any>;
155160

156161
protected readonly managers = new Map<string, PluginManagerExt>();
@@ -197,9 +202,10 @@ export class HostedPluginSupport {
197202
this.taskProviderRegistry.onWillProvideTaskProvider(event => this.ensureTaskActivation(event));
198203
this.taskResolverRegistry.onWillProvideTaskResolver(event => this.ensureTaskActivation(event));
199204
this.fileService.onWillActivateFileSystemProvider(event => this.ensureFileSystemActivation(event));
205+
this.customEditorRegistry.onPendingOpenCustomEditor(event => this.activateByCustomEditor(event));
200206

201207
this.widgets.onDidCreateWidget(({ factoryId, widget }) => {
202-
if (factoryId === WebviewWidget.FACTORY_ID && widget instanceof WebviewWidget) {
208+
if ((factoryId === WebviewWidget.FACTORY_ID || factoryId === CustomEditorWidget.FACTORY_ID) && widget instanceof WebviewWidget) {
203209
const storeState = widget.storeState.bind(widget);
204210
const restoreState = widget.restoreState.bind(widget);
205211

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

565+
async activateByCustomEditor(viewType: string): Promise<void> {
566+
await this.activateByEvent(`onCustomEditor:${viewType}`);
567+
}
568+
559569
activateByFileSystem(event: FileSystemProviderActivationEvent): Promise<void> {
560570
return this.activateByEvent(`onFileSystem:${event.scheme}`);
561571
}
@@ -713,10 +723,17 @@ export class HostedPluginSupport {
713723
this.webviewRevivers.delete(viewType);
714724
}
715725

716-
protected preserveWebviews(): void {
726+
protected async preserveWebviews(): Promise<void> {
717727
for (const webview of this.widgets.getWidgets(WebviewWidget.FACTORY_ID)) {
718728
this.preserveWebview(webview as WebviewWidget);
719729
}
730+
for (const webview of this.widgets.getWidgets(CustomEditorWidget.FACTORY_ID)) {
731+
(webview as CustomEditorWidget).modelRef.dispose();
732+
if ((webview as any)['closeWithoutSaving']) {
733+
delete (webview as any)['closeWithoutSaving'];
734+
}
735+
this.customEditorRegistry.resolveWidget(webview as CustomEditorWidget);
736+
}
720737
}
721738

722739
protected preserveWebview(webview: WebviewWidget): void {

0 commit comments

Comments
 (0)