// ***************************************************************************** // Copyright (C) 2023 TypeFox and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** /*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { CancellationToken, Disposable, DisposableCollection, Emitter, Event, URI } from '@theia/core'; import { URI as TheiaURI } from '../types-impl'; import type * as theia from '@theia/plugin'; import { NotebookCellStatusBarListDto, NotebookDataDto, NotebookDocumentsAndEditorsDelta, NotebookDocumentShowOptions, NotebookDocumentsMain, NotebookEditorAddData, NotebookEditorsMain, NotebooksExt, NotebooksMain, Plugin, PLUGIN_RPC_CONTEXT } from '../../common'; import { Cache } from '../../common/cache'; import { RPCProtocol } from '../../common/rpc-protocol'; import { UriComponents } from '../../common/uri-components'; import { CommandRegistryImpl, CommandsConverter } from '../command-registry'; import * as typeConverters from '../type-converters'; import { BinaryBuffer } from '@theia/core/lib/common/buffer'; import { Cell, NotebookDocument } from './notebook-document'; import { NotebookEditor } from './notebook-editor'; import { EditorsAndDocumentsExtImpl } from '../editors-and-documents'; import { DocumentsExtImpl } from '../documents'; import { CellUri, NotebookCellModelResource, NotebookModelResource } from '@theia/notebook/lib/common'; import { PluginLogger } from '../logger'; export class NotebooksExtImpl implements NotebooksExt { private readonly notebookStatusBarItemProviders = new Map(); private readonly commandsConverter: CommandsConverter; private readonly onDidChangeActiveNotebookEditorEmitter = new Emitter(); readonly onDidChangeActiveNotebookEditor = this.onDidChangeActiveNotebookEditorEmitter.event; private readonly onDidOpenNotebookDocumentEmitter = new Emitter(); onDidOpenNotebookDocument: Event = this.onDidOpenNotebookDocumentEmitter.event; private readonly onDidCloseNotebookDocumentEmitter = new Emitter(); onDidCloseNotebookDocument: Event = this.onDidCloseNotebookDocumentEmitter.event; private readonly onDidChangeVisibleNotebookEditorsEmitter = new Emitter(); onDidChangeVisibleNotebookEditors = this.onDidChangeVisibleNotebookEditorsEmitter.event; private activeNotebookEditor: NotebookEditor | undefined; get activeApiNotebookEditor(): theia.NotebookEditor | undefined { return this.activeNotebookEditor?.apiEditor; } private visibleNotebookEditors: NotebookEditor[] = []; get visibleApiNotebookEditors(): theia.NotebookEditor[] { return this.visibleNotebookEditors.map(editor => editor.apiEditor); } private readonly documents = new Map(); private readonly editors = new Map(); private statusBarRegistry = new Cache('NotebookCellStatusBarCache'); private readonly notebookProxy: NotebooksMain; private readonly notebookDocumentsProxy: NotebookDocumentsMain; private readonly notebookEditors: NotebookEditorsMain; private readonly logger: PluginLogger; constructor( rpc: RPCProtocol, commands: CommandRegistryImpl, private textDocumentsAndEditors: EditorsAndDocumentsExtImpl, private textDocuments: DocumentsExtImpl, ) { this.commandsConverter = commands.converter; this.notebookProxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.NOTEBOOKS_MAIN); this.notebookDocumentsProxy = rpc.getProxy(PLUGIN_RPC_CONTEXT.NOTEBOOK_DOCUMENTS_MAIN); this.notebookEditors = rpc.getProxy(PLUGIN_RPC_CONTEXT.NOTEBOOK_EDITORS_MAIN); this.logger = new PluginLogger(rpc, 'notebook'); commands.registerArgumentProcessor({ processArgument: arg => { if (NotebookModelResource.is(arg)) { return this.documents.get(arg.notebookModelUri.toString())?.apiNotebook; } else if (NotebookCellModelResource.is(arg)) { const cellUri = CellUri.parse(arg.notebookCellModelUri); if (cellUri) { return this.documents.get(cellUri?.notebook.toString())?.getCell(cellUri.handle)?.apiCell; } return undefined; } else { return arg; } } }); textDocumentsAndEditors.onDidChangeActiveTextEditor(e => { if (e && e?.document.uri.scheme !== CellUri.cellUriScheme && this.activeNotebookEditor) { this.activeNotebookEditor = undefined; this.onDidChangeActiveNotebookEditorEmitter.fire(undefined); } }); } async $provideNotebookCellStatusBarItems(handle: number, uri: UriComponents, index: number, token: CancellationToken): Promise { const provider = this.notebookStatusBarItemProviders.get(handle); const revivedUri = URI.fromComponents(uri); const document = this.documents.get(revivedUri.toString()); if (!document || !provider) { return; } const cell = document.getCellFromIndex(index); if (!cell) { return; } const result = await provider.provideCellStatusBarItems(cell.apiCell, token); if (!result) { return undefined; } const disposables = new DisposableCollection(); const cacheId = this.statusBarRegistry.add([disposables]); const resultArr = Array.isArray(result) ? result : [result]; const items = resultArr.map(item => typeConverters.NotebookStatusBarItem.from(item, this.commandsConverter, disposables)); return { cacheId, items }; } $releaseNotebookCellStatusBarItems(cacheId: number): void { this.statusBarRegistry.delete(cacheId); } $acceptActiveCellEditorChange(newActiveEditor: string | null): void { const newActiveEditorId = this.textDocumentsAndEditors.allEditors().find(editor => editor.document.uri.toString() === newActiveEditor)?.id; if (newActiveEditorId || newActiveEditor === null) { this.textDocumentsAndEditors.acceptEditorsAndDocumentsDelta({ newActiveEditor: newActiveEditorId ?? null }); } } // --- serialize/deserialize private currentSerializerHandle = 0; private readonly notebookSerializer = new Map(); registerNotebookSerializer(plugin: Plugin, viewType: string, serializer: theia.NotebookSerializer, options?: theia.NotebookDocumentContentOptions): theia.Disposable { if (!viewType || !viewType.trim()) { throw new Error('viewType cannot be empty or just whitespace'); } const handle = this.currentSerializerHandle++; this.notebookSerializer.set(handle, serializer); this.notebookProxy.$registerNotebookSerializer( handle, viewType, typeConverters.NotebookDocumentContentOptions.from(options), ); return Disposable.create(() => { this.notebookProxy.$unregisterNotebookSerializer(handle); }); } async $dataToNotebook(handle: number, bytes: BinaryBuffer, token: CancellationToken): Promise { const serializer = this.notebookSerializer.get(handle); if (!serializer) { throw new Error('No serializer found'); } const data = await serializer.deserializeNotebook(bytes.buffer, token); return typeConverters.NotebookData.from(data); } async $notebookToData(handle: number, data: NotebookDataDto, token: CancellationToken): Promise { const serializer = this.notebookSerializer.get(handle); if (!serializer) { throw new Error('No serializer found'); } const bytes = await serializer.serializeNotebook(typeConverters.NotebookData.to(data), token); return BinaryBuffer.wrap(bytes); } registerNotebookCellStatusBarItemProvider(notebookType: string, provider: theia.NotebookCellStatusBarItemProvider): theia.Disposable { const handle = this.currentSerializerHandle++; const eventHandle = typeof provider.onDidChangeCellStatusBarItems === 'function' ? this.currentSerializerHandle++ : undefined; this.notebookStatusBarItemProviders.set(handle, provider); this.notebookProxy.$registerNotebookCellStatusBarItemProvider(handle, eventHandle, notebookType); let subscription: theia.Disposable | undefined; if (eventHandle !== undefined) { subscription = provider.onDidChangeCellStatusBarItems!(_ => this.notebookProxy.$emitCellStatusBarEvent(eventHandle)); } return Disposable.create(() => { this.notebookStatusBarItemProviders.delete(handle); this.notebookProxy.$unregisterNotebookCellStatusBarItemProvider(handle, eventHandle); subscription?.dispose(); }); } getEditorById(editorId: string): NotebookEditor { const editor = this.editors.get(editorId); if (!editor) { throw new Error(`unknown text editor: ${editorId}. known editors: ${[...this.editors.keys()]} `); } return editor; } getAllApiDocuments(): theia.NotebookDocument[] { return [...this.documents.values()].map(doc => doc.apiNotebook); } async $acceptDocumentsAndEditorsDelta(delta: NotebookDocumentsAndEditorsDelta): Promise { const removedCellDocuments: UriComponents[] = []; if (delta.removedDocuments) { for (const uri of delta.removedDocuments) { const revivedUri = URI.fromComponents(uri); const document = this.documents.get(revivedUri.toString()); if (document) { document.dispose(); this.documents.delete(revivedUri.toString()); this.onDidCloseNotebookDocumentEmitter.fire(document.apiNotebook); removedCellDocuments.push(...document.apiNotebook.getCells().map(cell => cell.document.uri)); } for (const editor of this.editors.values()) { if (editor.notebookData.uri.toString() === revivedUri.toString()) { this.editors.delete(editor.id); } } } } if (removedCellDocuments.length > 0) { // publish all removed cell documents first this.textDocumentsAndEditors.acceptEditorsAndDocumentsDelta({ removedDocuments: removedCellDocuments }); } if (delta.addedDocuments) { for (const modelData of delta.addedDocuments) { const uri = TheiaURI.from(modelData.uri); if (this.documents.has(uri.toString())) { throw new Error(`cannot add EXISTING notebook ${uri}`); } const document = new NotebookDocument( this.notebookDocumentsProxy, this.textDocumentsAndEditors, this.textDocuments, uri, modelData ); this.documents.get(uri.toString())?.dispose(); this.documents.set(uri.toString(), document); if (modelData.cells.length > 0) { // Publish new cell documents before calling the notebook document open event // During this event, extensions might request the cell document and we want to make sure it is available this.textDocumentsAndEditors.acceptEditorsAndDocumentsDelta({ addedDocuments: modelData.cells.map(cell => Cell.asModelAddData(cell)) }); } this.onDidOpenNotebookDocumentEmitter.fire(document.apiNotebook); } } if (delta.addedEditors) { for (const editorModelData of delta.addedEditors) { if (this.editors.has(editorModelData.id)) { return; } const revivedUri = URI.fromComponents(editorModelData.documentUri); const document = this.documents.get(revivedUri.toString()); if (document) { this.createExtHostEditor(document, editorModelData.id, editorModelData); } } } const removedEditors: NotebookEditor[] = []; if (delta.removedEditors) { for (const editorId of delta.removedEditors) { const editor = this.editors.get(editorId); if (editor) { this.editors.delete(editorId); if (this.activeNotebookEditor?.id === editor.id) { this.activeNotebookEditor = undefined; } removedEditors.push(editor); } } } if (delta.visibleEditors) { this.visibleNotebookEditors = delta.visibleEditors.map(id => this.editors.get(id)!).filter(editor => !!editor) as NotebookEditor[]; const visibleEditorsSet = new Set(); this.visibleNotebookEditors.forEach(editor => visibleEditorsSet.add(editor.id)); for (const editor of this.editors.values()) { const newValue = visibleEditorsSet.has(editor.id); editor.acceptVisibility(newValue); } this.visibleNotebookEditors = [...this.editors.values()].map(e => e).filter(e => e.visible); this.onDidChangeVisibleNotebookEditorsEmitter.fire(this.visibleApiNotebookEditors); } if (delta.newActiveEditor === null) { // clear active notebook as current active editor is non-notebook editor this.activeNotebookEditor = undefined; this.onDidChangeActiveNotebookEditorEmitter.fire(undefined); } else if (delta.newActiveEditor) { const activeEditor = this.editors.get(delta.newActiveEditor); if (!activeEditor) { this.logger.error(`FAILED to find active notebook editor '${delta.newActiveEditor}'.`); } this.activeNotebookEditor = this.editors.get(delta.newActiveEditor); this.onDidChangeActiveNotebookEditorEmitter.fire(this.activeNotebookEditor?.apiEditor); const newActiveCell = this.activeApiNotebookEditor?.notebook.cellAt(this.activeApiNotebookEditor.selection.start); this.textDocumentsAndEditors.acceptEditorsAndDocumentsDelta({ newActiveEditor: newActiveCell?.kind === 2 /* code cell */ ? this.textDocumentsAndEditors.allEditors().find(editor => editor.document.uri.toString() === newActiveCell.document.uri.toString())?.id ?? null : null }); } } getNotebookDocument(uri: TheiaURI, relaxed: true): NotebookDocument | undefined; getNotebookDocument(uri: TheiaURI): NotebookDocument; getNotebookDocument(uri: TheiaURI, relaxed?: true): NotebookDocument | undefined { const result = this.documents.get(uri.toString()); if (!result && !relaxed) { throw new Error(`NO notebook document for '${uri}'`); } return result; } waitForNotebookDocument(uri: TheiaURI, duration = 2000): Promise { const existing = this.getNotebookDocument(uri, true); if (existing) { return Promise.resolve(existing); } return new Promise((resolve, reject) => { const listener = this.onDidOpenNotebookDocument(event => { if (event.uri.toString() === uri.toString()) { clearTimeout(timeout); listener.dispose(); resolve(this.getNotebookDocument(uri)); } }); const timeout = setTimeout(() => { listener.dispose(); reject(new Error(`Notebook document did NOT open in ${duration}ms: ${uri}`)); }, duration); }); } private createExtHostEditor(document: NotebookDocument, editorId: string, data: NotebookEditorAddData): void { if (this.editors.has(editorId)) { throw new Error(`editor with id ALREADY EXISTS: ${editorId}`); } const editor = new NotebookEditor( editorId, document, data.visibleRanges.map(typeConverters.NotebookRange.to), data.selections.map(typeConverters.NotebookRange.to), typeof data.viewColumn === 'number' ? typeConverters.ViewColumn.to(data.viewColumn) : undefined ); this.editors.set(editorId, editor); } private waitForNotebookEditor(editorId: string, duration = 2000): Promise { const existing = this.editors.get(editorId); if (existing) { return Promise.resolve(existing.apiEditor); } return new Promise((resolve, reject) => { const listener = this.onDidChangeVisibleNotebookEditors(() => { const editor = this.editors.get(editorId); if (editor) { clearTimeout(timeout); listener.dispose(); resolve(editor.apiEditor); } }); const timeout = setTimeout(() => { listener.dispose(); reject(new Error(`Notebook editor did NOT open in ${duration}ms: ${editorId}`)); }, duration); }); } async createNotebookDocument(options: { viewType: string; content?: theia.NotebookData }): Promise { const canonicalUri = await this.notebookDocumentsProxy.$tryCreateNotebook({ viewType: options.viewType, content: options.content && typeConverters.NotebookData.from(options.content) }); return TheiaURI.from(canonicalUri); } async openNotebookDocument(uri: TheiaURI): Promise { const cached = this.documents.get(uri.toString()); if (cached) { return cached.apiNotebook; } const canonicalUri = await this.notebookDocumentsProxy.$tryOpenNotebook(uri); const document = this.documents.get(URI.fromComponents(canonicalUri).toString()); return document?.apiNotebook!; } async showNotebookDocument(notebookOrUri: theia.NotebookDocument | TheiaURI, options?: theia.NotebookDocumentShowOptions): Promise { if (TheiaURI.isUri(notebookOrUri)) { notebookOrUri = await this.openNotebookDocument(notebookOrUri as TheiaURI); } const notebook = notebookOrUri; let resolvedOptions: NotebookDocumentShowOptions; if (typeof options === 'object') { resolvedOptions = { position: typeConverters.ViewColumn.from(options.viewColumn), preserveFocus: options.preserveFocus, selections: options.selections && options.selections.map(typeConverters.NotebookRange.from), pinned: typeof options.preview === 'boolean' ? !options.preview : undefined }; } else { resolvedOptions = { preserveFocus: false }; } const editorId = await this.notebookEditors.$tryShowNotebookDocument(notebook.uri, notebook.notebookType, resolvedOptions); const editor = editorId && await this.waitForNotebookEditor(editorId); if (editor) { return editor; } if (editorId) { throw new Error(`Could NOT open editor for "${notebook.uri.toString()}" because another editor opened in the meantime.`); } else { throw new Error(`Could NOT open editor for "${notebook.uri.toString()}".`); } } }