diff --git a/src/vs/workbench/contrib/positronNotebook/browser/PositronNotebookCell.ts b/src/vs/workbench/contrib/positronNotebook/browser/PositronNotebookCell.ts index 88fa4d9b1c0..1a694266bf3 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/PositronNotebookCell.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/PositronNotebookCell.ts @@ -11,7 +11,7 @@ import { ITextModelService } from 'vs/editor/common/services/resolverService'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; import { CellKind } from 'vs/workbench/contrib/notebook/common/notebookCommon'; -import { ExecutionStatus, IPositronNotebookCodeCell, IPositronNotebookCell, IPositronNotebookMarkdownCell, NotebookCellOutputs } from 'vs/workbench/services/positronNotebook/browser/IPositronNotebookCell'; +import { ExecutionStatus, IPositronNotebookCodeCell, IPositronNotebookCell, IPositronNotebookMarkdownCell, NotebookCellOutputs, NotebookCellOutputItem } from 'vs/workbench/services/positronNotebook/browser/IPositronNotebookCell'; import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { CellSelectionType } from 'vs/workbench/services/positronNotebook/browser/selectionMachine'; import { PositronNotebookInstance } from 'vs/workbench/contrib/positronNotebook/browser/PositronNotebookInstance'; @@ -24,15 +24,12 @@ export abstract class PositronNotebookCellGeneral extends Disposable implements private _container: HTMLElement | undefined; private _editor: CodeEditorWidget | undefined; - - constructor( public cellModel: NotebookCellTextModel, public _instance: PositronNotebookInstance, @ITextModelService private readonly textModelResolverService: ITextModelService, ) { super(); - } get uri(): URI { @@ -219,4 +216,64 @@ export function createNotebookCell(cell: NotebookCellTextModel, instance: Positr } +/** + * Get the priority of a mime type for sorting purposes + * @param mime The mime type to get the priority of + * @returns A number representing the priority of the mime type. Lower numbers are higher priority. + */ +function getMimeTypePriority(mime: string): number | null { + if (mime.includes('application')) { + return 1; + } + switch (mime) { + case 'text/html': + return 2; + case 'image/png': + return 3; + case 'text/plain': + return 4; + default: + // Dont know what this is, so mark it as special so we know something went wrong + return null; + } +} + +/** + * Pick the output item with the highest priority mime type from a cell output object + * @param outputItems Array of outputs items data from a cell output object + * @returns The output item with the highest priority mime type. If there's a tie, the first one is + * returned. If there's an unknown mime type we defer to ones we do know about. + */ +export function pickPreferredOutputItem(outputItems: NotebookCellOutputItem[], logWarning: (msg: string) => void): NotebookCellOutputItem | undefined { + + if (outputItems.length === 0) { + return undefined; + } + + let highestPriority: number | null = null; + let preferredOutput = outputItems[0]; + + for (const item of outputItems) { + const priority = getMimeTypePriority(item.mime); + + // If we don't know how to render any of the mime types, we'll return the first one and hope + // for the best! + if (priority === null) { + continue; + } + + if (priority < (highestPriority ?? Infinity)) { + preferredOutput = item; + highestPriority = priority; + } + } + + if (highestPriority === null) { + logWarning('Could not determine preferred output for notebook cell with mime types' + + outputItems.map(item => item.mime).join(', ') + ); + } + + return preferredOutput; +} diff --git a/src/vs/workbench/contrib/positronNotebook/browser/PositronNotebookInstance.ts b/src/vs/workbench/contrib/positronNotebook/browser/PositronNotebookInstance.ts index 621b1d9057a..b844ed81ab1 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/PositronNotebookInstance.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/PositronNotebookInstance.ts @@ -2,8 +2,8 @@ * Copyright (C) 2024 Posit Software, PBC. All rights reserved. * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter, Event } from 'vs/base/common/event'; -import { Disposable, DisposableStore, dispose } from 'vs/base/common/lifecycle'; +import { Emitter } from 'vs/base/common/event'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { ISettableObservable, observableValue } from 'vs/base/common/observableInternal/base'; import { URI } from 'vs/base/common/uri'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; @@ -13,7 +13,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ILogService } from 'vs/platform/log/common/log'; import { insertCellAtIndex } from 'vs/workbench/contrib/notebook/browser/controller/cellOperations'; -import { IActiveNotebookEditorDelegate, IBaseCellEditorOptions, INotebookEditorCreationOptions, INotebookEditorViewState, INotebookViewCellsUpdateEvent } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; +import { IActiveNotebookEditorDelegate, IBaseCellEditorOptions, INotebookEditorCreationOptions, INotebookEditorViewState } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { NotebookOptions } from 'vs/workbench/contrib/notebook/browser/notebookOptions'; import { NotebookViewModel } from 'vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl'; import { NotebookTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookTextModel'; @@ -34,6 +34,7 @@ import { disposableTimeout } from 'vs/base/common/async'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { SELECT_KERNEL_ID_POSITRON, SelectPositronNotebookKernelContext } from './SelectPositronNotebookKernelAction'; import { INotebookKernelService } from 'vs/workbench/contrib/notebook/common/notebookKernelService'; +import { INotebookService } from 'vs/workbench/contrib/notebook/common/notebookService'; interface IPositronNotebookInstanceRequiredViewModel extends IPositronNotebookInstance { viewModel: NotebookViewModel; @@ -44,6 +45,8 @@ interface IPositronNotebookInstanceRequiredTextModel extends IPositronNotebookIn export class PositronNotebookInstance extends Disposable implements IPositronNotebookInstance { + // ===== Statics ===== + // #region Statics /** * Either makes or retrieves an instance of a Positron Notebook based on the resource. This * helps avoid having multiple instances open for the same file when the input is rebuilt. @@ -74,36 +77,25 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot static _instanceMap: Map = new Map(); - /** * Value to keep track of what instance number. * Used for keeping track in the logs. */ - static count = 0; + static _count = 0; - private _identifier: string = `Positron Notebook | NotebookInstance(${PositronNotebookInstance.count++}) |`; + // #endregion - /** - * Internal cells that we use to manage the state of the notebook - */ - private _cells: IPositronNotebookCell[] = []; + // ============================================================================================= + // #region Private Properties - /** - * User facing cells wrapped in an observerable for the UI to react to changes - */ - cells: ISettableObservable; - selectedCells: ISettableObservable = observableValue('positronNotebookSelectedCells', []); - editingCell: ISettableObservable = observableValue('positronNotebookEditingCell', undefined); - - selectionStateMachine: SelectionStateMachine; - contextManager: PositronNotebookContextKeyManager; + private _identifier: string = `Positron Notebook | NotebookInstance(${PositronNotebookInstance._count++}) |`; /** - * Status of kernel for the notebook. + * Internal cells that we use to manage the state of the notebook */ - kernelStatus: ISettableObservable; + private _cells: IPositronNotebookCell[] = []; - private language: string | undefined = undefined; + private _language: string | undefined = undefined; /** * A set of disposables that are linked to a given model @@ -111,19 +103,8 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot */ private readonly _modelStore = this._register(new DisposableStore()); - /** - * Store of disposables. - */ - private readonly _localStore = this._register(new DisposableStore()); - private _container: HTMLElement | undefined = undefined; - /** - * Is the instance connected to an editor as indicated by having an associated container object? - */ - get connectedToEditor(): boolean { - return Boolean(this._container); - } /** * Callback to clear the keyboard navigation listeners. Set when listeners are attached. */ @@ -134,97 +115,119 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot */ private _baseCellEditorOptions: Map = new Map(); - /** - * Mirrored cell state listeners from the notebook model. - */ - private _localCellStateListeners: DisposableStore[] = []; - - get uri(): URI { - return this._input.resource; - } /** * View model for the notebook. */ private _viewModel: NotebookViewModel | undefined = undefined; - /** - * Returns view model. Type of unknown is used to deal with type import rules. Should be type-cast to NotebookViewModel. - */ - get viewModel(): NotebookViewModel | undefined { - return this._viewModel; - } - - private assertViewModel(): asserts this is IPositronNotebookInstanceRequiredViewModel { - if (this._viewModel === undefined) { - throw new Error('No view model for notebook'); - } - } - - /** - * Get the current `NotebookTextModel` for the editor. - */ - get textModel() { - return this._viewModel?.notebookDocument; - } - - private assertTextModel(): asserts this is IPositronNotebookInstanceRequiredTextModel { - if (this.textModel === undefined) { - throw new Error('No text model for notebook'); - } - } - + private _textModel: NotebookTextModel | undefined = undefined; /** * Internal event emitter for when the editor's options change. */ private readonly _onDidChangeOptions = this._register(new Emitter()); + + // #region NotebookModel /** - * Event emitter for when the editor's options change. + * Model for the notebook contents. Note the difference between the NotebookTextModel and the + * NotebookViewModel. */ - readonly onDidChangeOptions: Event = this._onDidChangeOptions.event; + private readonly _onDidChangeModel = this._register(new Emitter()); /** - * Internal event emitter for when the editor's decorations change. + * Options for how the notebook should be displayed. Currently not really used but will be as + * notebook gets fleshed out. */ - private readonly _onDidChangeDecorations = this._register(new Emitter()); + private _notebookOptions: NotebookOptions | undefined; + // #endregion + + + // ============================================================================================= + // #region Public Properties + /** - * Event emitter for when the editor's decorations change. + * User facing cells wrapped in an observerable for the UI to react to changes */ - readonly onDidChangeDecorations: Event = this._onDidChangeDecorations.event; + cells: ISettableObservable; + selectedCells: ISettableObservable = observableValue('positronNotebookSelectedCells', []); + editingCell: ISettableObservable = observableValue('positronNotebookEditingCell', undefined); + selectionStateMachine: SelectionStateMachine; + contextManager: PositronNotebookContextKeyManager; /** - * Internal event emitter for when the cells of the current view model change. + * Status of kernel for the notebook. */ - private readonly _onDidChangeViewCells = this._register(new Emitter()); + kernelStatus: ISettableObservable; + /** - * Event emitter for when the cells of the current view model change. + * Keep track of if this editor has been disposed. */ - readonly onDidChangeViewCells: Event = this._onDidChangeViewCells.event; + isDisposed: boolean = false; + + // #endregion + + // ============================================================================================= + + // #region Getters and Setters - // #region NotebookModel /** - * Model for the notebook contents. Note the difference between the NotebookTextModel and the - * NotebookViewModel. + * Is the instance connected to an editor as indicated by having an associated container object? */ - private readonly _onWillChangeModel = this._register(new Emitter()); + get connectedToEditor(): boolean { + return Boolean(this._container); + } + + get uri(): URI { + return this._input.resource; + } + /** - * Fires an event when the notebook model for the editor is about to change. The argument is the - * outgoing `NotebookTextModel` model. + * Returns view model. Type of unknown is used to deal with type import rules. Should be type-cast to NotebookViewModel. */ - readonly onWillChangeModel: Event = this._onWillChangeModel.event; - private readonly _onDidChangeModel = this._register(new Emitter()); + get viewModel(): NotebookViewModel | undefined { + return this._viewModel; + } /** - * Fires an event when the notebook model for the editor has changed. The argument is the new - * `NotebookTextModel` model. + * Get the current `NotebookTextModel` for the editor. */ - readonly onDidChangeModel: Event = this._onDidChangeModel.event; + get textModel() { + return this._textModel; + } + + + get isReadOnly(): boolean { + return this._creationOptions?.isReadOnly ?? false; + } /** - * Keep track of if this editor has been disposed. + * Gets the notebook options for the editor. + * Exposes the private internal notebook options as a get only property. */ - isDisposed: boolean = false; + get notebookOptions() { + + if (this._notebookOptions) { + return this._notebookOptions; + } + this._logService.info(this._identifier, 'Generating new notebook options'); + + this._notebookOptions = this._creationOptions?.options ?? new NotebookOptions( + DOM.getActiveWindow(), + this.isReadOnly, + undefined, + this.configurationService, + this.notebookExecutionStateService, + this._codeEditorService + ); + + return this._notebookOptions; + } + + // #endregion + + // ============================================================================================= + // #region Lifecycle constructor( private _input: PositronNotebookEditorInput, @@ -232,6 +235,7 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot @ICommandService private readonly _commandService: ICommandService, @INotebookExecutionService private readonly notebookExecutionService: INotebookExecutionService, @INotebookExecutionStateService private readonly notebookExecutionStateService: INotebookExecutionStateService, + @INotebookService private readonly _notebookService: INotebookService, @INotebookKernelService private readonly notebookKernelService: INotebookKernelService, @IConfigurationService private readonly configurationService: IConfigurationService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -245,20 +249,38 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot this.cells = observableValue('positronNotebookCells', this._cells); this.kernelStatus = observableValue('positronNotebookKernelStatus', KernelStatus.Uninitialized); - this._setupNotebookTextModel(); - this.contextManager = this._instantiationService.createInstance(PositronNotebookContextKeyManager); - this.selectionStateMachine = this._register(this._instantiationService.createInstance(SelectionStateMachine)); - this._positronNotebookService.registerInstance(this); + this.selectionStateMachine = this._register( + this._instantiationService.createInstance(SelectionStateMachine) + ); + + this._register( + this._notebookService.onDidAddNotebookDocument((model) => { + // Is this our notebook? + if (this._isThisNotebook(model.uri)) { + this._setupNotebookTextModel(); + } + }) + ); + + this._register( + this._notebookService.onDidRemoveNotebookDocument((model) => { + // Is this our notebook? + if (this._isThisNotebook(model.uri)) { + this._detachModel(); + } + }) + ); + this._register( this.notebookKernelService.onDidChangeSelectedNotebooks((e) => { // If this is our notebook, update the kernel status as needed. const isThisNotebook = e.notebook.path === this._input.resource.path; if (!isThisNotebook) { return; } - this.assertTextModel(); + this._assertTextModel(); // Select the kernel const kernel = this.notebookKernelService.getSelectedOrSuggestedKernel(this.textModel); @@ -267,133 +289,32 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot return; } + this._logService.info(this._identifier, `Selecting kernel ${kernel.id} for notebook`); + this.notebookKernelService.selectKernelForNotebook(kernel, this.textModel); - console.log('Kernel changed for notebook', e); - const hasKernel = e.newKernel !== undefined; - this.kernelStatus.set(hasKernel ? KernelStatus.Connected : KernelStatus.Disconnected, undefined); + this.kernelStatus.set(e.newKernel !== undefined ? KernelStatus.Connected : KernelStatus.Disconnected, undefined); }) ); this._logService.info(this._identifier, 'constructor'); } - get isReadOnly(): boolean { - return this._creationOptions?.isReadOnly ?? false; - } - - /** - * Gets the notebook options for the editor. - * Exposes the private internal notebook options as a get only property. - */ - get notebookOptions() { - - if (this._notebookOptions) { - return this._notebookOptions; - } - this._logService.info(this._identifier, 'Generating new notebook options'); + override dispose() { - this._notebookOptions = this._creationOptions?.options ?? new NotebookOptions( - DOM.getActiveWindow(), - this.isReadOnly, - undefined, - this.configurationService, - this.notebookExecutionStateService, - this._codeEditorService - ); + this._logService.info(this._identifier, 'dispose'); + this._positronNotebookService.unregisterInstance(this); - return this._notebookOptions; + super.dispose(); + this.detachView(); } - /** - * Options for how the notebook should be displayed. Currently not really used but will be as - * notebook gets fleshed out. - */ - private _notebookOptions: NotebookOptions | undefined; - - - /** - * Handle logic associated with the text model for notebook. This - * includes setting up listeners for changes to the model and - * setting up the initial state of the notebook. - */ - private async _setupNotebookTextModel() { - const model = await this._input.resolve(); - if (model === null) { - throw new Error( - localize( - 'fail.noModel', - 'Failed to find a model for view type {0}.', - this._input.viewType - ) - ); - } - - const notebookModel = model.notebook; - - const fillCells = () => { - - const cellModelToCellMap = new Map( - this._cells.map(cell => [cell.cellModel, cell]) - ); - - const newlyAddedCells: IPositronNotebookCell[] = []; - - // Update cells with new cells - this._cells = notebookModel.cells.map(cell => { - const existingCell = cellModelToCellMap.get(cell); - if (existingCell) { - // Remove cell from map so we know it's been used. - cellModelToCellMap.delete(cell); - return existingCell; - } - const newCell = createNotebookCell(cell, this, this._instantiationService); - newlyAddedCells.push(newCell); - - return newCell; - }); - - if (newlyAddedCells.length === 1) { - // If we've only added one cell, we can set it as the selected cell. - this._register(disposableTimeout(() => { - this.selectionStateMachine.selectCell(newlyAddedCells[0], CellSelectionType.Edit); - newlyAddedCells[0].focusEditor(); - }, 0)); - } - - // Dispose of any cells that were not reused. - cellModelToCellMap.forEach(cell => cell.dispose()); - - this.language = notebookModel.cells[0].language; - this.cells.set(this._cells, undefined); - this.selectionStateMachine.setCells(this._cells); - }; - - fillCells(); - - this.assertTextModel(); - - // TODO: Make sure this is cleaned up properly. - this._modelStore.add(this.textModel); - this._modelStore.add( - this.textModel.onDidChangeContent((e) => { - // Only update cells if the number of cells has changed. Aka we've added or removed - // cells. There's a chance this is not smart enough. E.g. it may be possible to - // swap cells in the notebook and this would not catch that. - const numOldCells = this._cells.length; - const numNewCells = notebookModel.cells.length; - - if (numOldCells === numNewCells) { - return; - } + // #endregion - fillCells(); - }) - ); - } + // ============================================================================================= + // #region Public Methods async runCells(cells: IPositronNotebookCell[]): Promise { - if (!cells) { throw new Error(localize('noCells', "No cells to run")); } @@ -404,53 +325,11 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot await this._runCells(this._cells); } - /** - * Internal method to run cells, used by other cell running methods. - * @param cells Cells to run - * @returns - */ - private async _runCells(cells: IPositronNotebookCell[]): Promise { - // Filter so we're only working with code cells. - const codeCells = cells; - this._logService.info(this._identifier, '_runCells'); - - this.assertTextModel(); - - // Make sure we have a kernel to run the cells. - if (this.kernelStatus.get() !== KernelStatus.Connected) { - this._logService.info(this._identifier, 'No kernel connected, attempting to connect'); - // Attempt to connect to the kernel - await this._commandService.executeCommand( - SELECT_KERNEL_ID_POSITRON, - { forceDropdown: false } satisfies SelectPositronNotebookKernelContext - ); - } - - for (const cell of codeCells) { - if (cell.isCodeCell()) { - cell.executionStatus.set('running', undefined); - } - } - - const hasExecutions = [...cells].some(cell => Boolean(this.notebookExecutionStateService.getCellExecution(cell.uri))); - - if (hasExecutions) { - this.notebookExecutionService.cancelNotebookCells(this.textModel, Array.from(cells).map(c => c.cellModel as NotebookCellTextModel)); - return; - } - - await this.notebookExecutionService.executeNotebookCells(this.textModel, Array.from(cells).map(c => c.cellModel as NotebookCellTextModel), this._contextKeyService); - for (const cell of codeCells) { - if (cell.isCodeCell()) { - cell.executionStatus.set('idle', undefined); - } - } - } addCell(type: CellKind, index: number): void { - this.assertViewModel(); + this._assertViewModel(); - if (!this.language) { + if (!this._language) { throw new Error(localize('noLanguage', "No language for notebook")); } const synchronous = true; @@ -459,7 +338,7 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot this.viewModel, index, '', - this.language, + this._language, type, undefined, [], @@ -478,7 +357,7 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot } deleteCell(cellToDelete?: IPositronNotebookCell): void { - this.assertTextModel(); + this._assertTextModel(); const cell = cellToDelete ?? this.selectionStateMachine.getSelectedCell(); @@ -519,17 +398,6 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot } - /** - * Set the currently selected cells for notebook instance - * @param cellOrCells The cell or cells to set as selected - */ - setSelectedCells(cells: IPositronNotebookCell[]): void { - this.selectionStateMachine.selectCell(cells[0], CellSelectionType.Normal); - } - - deselectCell(cell: IPositronNotebookCell): void { - this.selectionStateMachine.deselectCell(cell); - } setEditingCell(cell: IPositronNotebookCell | undefined): void { if (cell === undefined) { @@ -556,11 +424,6 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot const notifyOfModelChange = true; - if (notifyOfModelChange) { - // Fire on will change with old model - this._onWillChangeModel.fire(this._viewModel?.notebookDocument); - } - this._viewModel = viewModel; if (notifyOfModelChange) { @@ -571,65 +434,11 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot // Bring the view model back to the state it was in when the view state was saved. this._viewModel?.restoreEditorViewState(viewState); - if (this._viewModel) { - this._localStore.add(this._viewModel.onDidChangeViewCells(e => { - this._onDidChangeViewCells.fire(e); - })); - } - this._setupKeyboardNavigation(container); this._logService.info(this._identifier, 'attachView'); } - /** - * Setup keyboard navigation for the current notebook. - * @param container The main containing node the notebook is rendered into - */ - private _setupKeyboardNavigation(container: HTMLElement) { - // Add some keyboard navigation for cases not covered by the keybindings. I'm not sure if - // there's a way to do this directly with keybindings but this feels acceptable due to the - // ubiquity of the enter key and escape keys for these types of actions. - const onKeyDown = ({ key, shiftKey, ctrlKey, metaKey }: KeyboardEvent) => { - if (key === 'Enter' && !(ctrlKey || metaKey || shiftKey)) { - this.selectionStateMachine.enterEditor(); - } else if (key === 'Escape') { - this.selectionStateMachine.exitEditor(); - } - }; - - this._container?.addEventListener('keydown', onKeyDown); - - this._clearKeyboardNavigation = () => { - this._container?.removeEventListener('keydown', onKeyDown); - }; - } - - /** - * Remove and cleanup the current model for notebook. - */ - private _detachModel() { - this._logService.info(this._identifier, 'detachModel'); - // Clear store of disposables - this._localStore.clear(); - - // Dispose of all cell state listeners from the outgoing model - dispose(this._localCellStateListeners); - - this._viewModel?.dispose(); - this._viewModel = undefined; - } - - // #endregion - - /** - * Type guard to check if the editor has a model. - * @returns True if the editor has a model, false otherwise. - */ - hasModel(): this is IActiveNotebookEditorDelegate { - return Boolean(this._viewModel); - } - /** * Gets the base cell editor options for the given language. * If they don't exist yet, they will be created. @@ -643,9 +452,9 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot } const options = new BaseCellEditorOptions({ - onDidChangeModel: this.onDidChangeModel, - hasModel: this.hasModel, - onDidChangeOptions: this.onDidChangeOptions, + onDidChangeModel: this._onDidChangeModel.event, + hasModel: <() => this is IActiveNotebookEditorDelegate>(() => Boolean(this._viewModel)), + onDidChangeOptions: this._onDidChangeOptions.event, isReadOnly: this.isReadOnly, }, this.notebookOptions, this.configurationService, language); this._baseCellEditorOptions.set(language, options); @@ -674,16 +483,198 @@ export class PositronNotebookInstance extends Disposable implements IPositronNot this._clearKeyboardNavigation?.(); this._notebookOptions?.dispose(); this._detachModel(); - this._localStore.clear(); } - override dispose() { + // #endregion - this._logService.info(this._identifier, 'dispose'); - this._positronNotebookService.unregisterInstance(this); + // ============================================================================================= + // #region Private Methods - super.dispose(); - this.detachView(); + private _assertViewModel(): asserts this is IPositronNotebookInstanceRequiredViewModel { + if (this._viewModel === undefined) { + throw new Error('No view model for notebook'); + } + } + + + private _assertTextModel(): asserts this is IPositronNotebookInstanceRequiredTextModel { + if (this.textModel === undefined) { + throw new Error('No text model for notebook'); + } + } + + /** + * Helper to determine if the given URI is the same as the notebook's associated with + * this instance. + * @param uri Uri to check against the notebook's uri + * @returns True if the uri is the same as the notebook's uri, false otherwise. + */ + private _isThisNotebook(uri: URI): boolean { + return uri.toString() === this._input.resource.toString(); + } + + + /** + * Handle logic associated with the text model for notebook. This + * includes setting up listeners for changes to the model and + * setting up the initial state of the notebook. + */ + private async _setupNotebookTextModel() { + const model = await this._input.resolve(); + if (model === null) { + throw new Error( + localize( + 'fail.noModel', + 'Failed to find a model for view type {0}.', + this._input.viewType + ) + ); + } + + this._textModel = model.notebook; + + this._syncCells(); + + this._modelStore.add( + this._textModel.onDidChangeContent((e) => { + // Only update cells if the number of cells has changed. Aka we've added or removed + // cells. There's a chance this is not smart enough. E.g. it may be possible to + // swap cells in the notebook and this would not catch that. + const numOldCells = this._cells.length; + const numNewCells = this._textModel?.cells.length; + + if (numOldCells === numNewCells) { + return; + } + + this._syncCells(); + }) + ); + } + + /** + * Method to sync the editor cells with the current cells in the model. + */ + private _syncCells() { + const modelCells = this._textModel?.cells; + + if (!modelCells) { + throw new Error('No cells in notebook model to fill editor with.'); + } + + const cellModelToCellMap = new Map( + this._cells.map(cell => [cell.cellModel, cell]) + ); + + const newlyAddedCells: IPositronNotebookCell[] = []; + + this._cells = modelCells.map(cell => { + const existingCell = cellModelToCellMap.get(cell); + if (existingCell) { + // Remove cell from map so we know it's been used. + cellModelToCellMap.delete(cell); + return existingCell; + } + const newCell = createNotebookCell(cell, this, this._instantiationService); + newlyAddedCells.push(newCell); + + return newCell; + }); + + if (newlyAddedCells.length === 1) { + // If we've only added one cell, we can set it as the selected cell. + this._register(disposableTimeout(() => { + this.selectionStateMachine.selectCell(newlyAddedCells[0], CellSelectionType.Edit); + newlyAddedCells[0].focusEditor(); + }, 0)); + } + + // Dispose of any cells that were not reused. + cellModelToCellMap.forEach(cell => cell.dispose()); + + this._language = modelCells[0].language; + this.cells.set(this._cells, undefined); + this.selectionStateMachine.setCells(this._cells); + } + + /** + * Internal method to run cells, used by other cell running methods. + * @param cells Cells to run + * @returns + */ + private async _runCells(cells: IPositronNotebookCell[]): Promise { + // Filter so we're only working with code cells. + const codeCells = cells; + this._logService.info(this._identifier, '_runCells'); + + this._assertTextModel(); + + // Make sure we have a kernel to run the cells. + if (this.kernelStatus.get() !== KernelStatus.Connected) { + this._logService.info(this._identifier, 'No kernel connected, attempting to connect'); + // Attempt to connect to the kernel + await this._commandService.executeCommand( + SELECT_KERNEL_ID_POSITRON, + { forceDropdown: false } satisfies SelectPositronNotebookKernelContext + ); + } + + for (const cell of codeCells) { + if (cell.isCodeCell()) { + cell.executionStatus.set('running', undefined); + } + } + + const hasExecutions = [...cells].some(cell => Boolean(this.notebookExecutionStateService.getCellExecution(cell.uri))); + + if (hasExecutions) { + this.notebookExecutionService.cancelNotebookCells(this.textModel, Array.from(cells).map(c => c.cellModel as NotebookCellTextModel)); + return; + } + + await this.notebookExecutionService.executeNotebookCells(this.textModel, Array.from(cells).map(c => c.cellModel as NotebookCellTextModel), this._contextKeyService); + for (const cell of codeCells) { + if (cell.isCodeCell()) { + cell.executionStatus.set('idle', undefined); + } + } + } + + + /** + * Setup keyboard navigation for the current notebook. + * @param container The main containing node the notebook is rendered into + */ + private _setupKeyboardNavigation(container: HTMLElement) { + // Add some keyboard navigation for cases not covered by the keybindings. I'm not sure if + // there's a way to do this directly with keybindings but this feels acceptable due to the + // ubiquity of the enter key and escape keys for these types of actions. + const onKeyDown = ({ key, shiftKey, ctrlKey, metaKey }: KeyboardEvent) => { + if (key === 'Enter' && !(ctrlKey || metaKey || shiftKey)) { + this.selectionStateMachine.enterEditor(); + } else if (key === 'Escape') { + this.selectionStateMachine.exitEditor(); + } + }; + + this._container?.addEventListener('keydown', onKeyDown); + + this._clearKeyboardNavigation = () => { + this._container?.removeEventListener('keydown', onKeyDown); + }; + } + + /** + * Remove and cleanup the current model for notebook. + */ + private _detachModel() { + this._logService.info(this._identifier, 'detachModel'); + // Clear store of disposables + this._modelStore.clear(); + this._viewModel?.dispose(); + this._viewModel = undefined; } + + // #endregion } diff --git a/src/vs/workbench/contrib/positronNotebook/browser/getOutputContents.ts b/src/vs/workbench/contrib/positronNotebook/browser/getOutputContents.ts index 5549eaf0655..a6b2ff97160 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/getOutputContents.ts +++ b/src/vs/workbench/contrib/positronNotebook/browser/getOutputContents.ts @@ -93,6 +93,10 @@ type ParsedOutput = ParsedTextOutput | type: 'image'; dataUrl: string; } | +{ + type: 'html'; + content: string; +} | { type: 'interupt'; trace: string; @@ -141,6 +145,10 @@ export function parseOutputData(output: ICellOutput['outputs'][number]): ParsedO return { type: 'text', content: message }; } + if (mime === 'text/html') { + return { type: 'html', content: message }; + } + if (mime === 'image/png') { return { type: 'image', diff --git a/src/vs/workbench/contrib/positronNotebook/browser/notebookCells/NotebookCodeCell.tsx b/src/vs/workbench/contrib/positronNotebook/browser/notebookCells/NotebookCodeCell.tsx index 60e7c049f7a..8a820867eaa 100644 --- a/src/vs/workbench/contrib/positronNotebook/browser/notebookCells/NotebookCodeCell.tsx +++ b/src/vs/workbench/contrib/positronNotebook/browser/notebookCells/NotebookCodeCell.tsx @@ -5,8 +5,6 @@ import 'vs/css!./NotebookCodeCell'; import * as React from 'react'; -import { VSBuffer } from 'vs/base/common/buffer'; -import { NotebookCellOutputTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellOutputTextModel'; import { NotebookCellOutputs } from 'vs/workbench/services/positronNotebook/browser/IPositronNotebookCell'; import { isParsedTextOutput, parseOutputData } from 'vs/workbench/contrib/positronNotebook/browser/getOutputContents'; import { useObservedValue } from 'vs/workbench/contrib/positronNotebook/browser/useObservedValue'; @@ -16,7 +14,9 @@ import { NotebookCellActionBar } from 'vs/workbench/contrib/positronNotebook/bro import { CellTextOutput } from './CellTextOutput'; import { ActionButton } from 'vs/workbench/contrib/positronNotebook/browser/utilityComponents/ActionButton'; import { NotebookCellWrapper } from './NotebookCellWrapper'; -import { PositronNotebookCodeCell } from 'vs/workbench/contrib/positronNotebook/browser/PositronNotebookCell'; +import { pickPreferredOutputItem, PositronNotebookCodeCell } from 'vs/workbench/contrib/positronNotebook/browser/PositronNotebookCell'; +import { NotebookHTMLContent } from 'vs/workbench/contrib/positronNotebook/browser/notebookCells/NotebookHTMLOutput'; +import { useServices } from 'vs/workbench/contrib/positronNotebook/browser/ServicesProvider'; export function NotebookCodeCell({ cell }: { cell: PositronNotebookCodeCell }) { @@ -35,33 +35,24 @@ export function NotebookCodeCell({ cell }: { cell: PositronNotebookCodeCell }) {
- {outputContents?.map((output) => )} + {outputContents?.map(({ outputs, outputId }) => + + )}
; } -function NotebookCellOutput({ cellOutput }: { cellOutput: NotebookCellOutputs }) { +function CellOutput({ outputs }: Pick) { + const services = useServices(); + const preferredOutput = pickPreferredOutputItem(outputs, services.logService.warn); - const { outputs } = cellOutput; - - - if (cellOutput instanceof NotebookCellOutputTextModel) { - - return <> - {outputs.map(({ data, mime }, i) => )} - ; + if (!preferredOutput) { + return null; } - return
- {localize('cellExecutionUnknownOutputType', 'Can not handle output type: OutputId: {0}', cellOutput.outputId)} -
; -} - -function CellOutputContents(output: { data: VSBuffer; mime: string }) { - - const parsed = parseOutputData(output); + const parsed = parseOutputData(preferredOutput); if (isParsedTextOutput(parsed)) { return ; @@ -74,10 +65,11 @@ function CellOutputContents(output: { data: VSBuffer; mime: string }) { ; case 'image': return output image; + case 'html': + return ; case 'unknown': return
- {localize('cellExecutionUnknownMimeType', 'Cant handle mime type "{0}" yet', output.mime)} + {localize('cellExecutionUnknownMimeType', 'Cant handle mime type "{0}" yet', preferredOutput.mime)}
; } - } diff --git a/src/vs/workbench/contrib/positronNotebook/browser/notebookCells/NotebookHTMLOutput.tsx b/src/vs/workbench/contrib/positronNotebook/browser/notebookCells/NotebookHTMLOutput.tsx new file mode 100644 index 00000000000..3e59da7e790 --- /dev/null +++ b/src/vs/workbench/contrib/positronNotebook/browser/notebookCells/NotebookHTMLOutput.tsx @@ -0,0 +1,136 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2024 Posit Software, PBC. All rights reserved. + * Licensed under the Elastic License 2.0. See LICENSE.txt for license information. + *--------------------------------------------------------------------------------------------*/ +import 'vs/css!./NotebookCodeCell'; + +import * as React from 'react'; +import { getWindow } from 'vs/base/browser/dom'; +import { localize } from 'vs/nls'; +import { transformWebviewThemeVars } from 'vs/workbench/contrib/notebook/browser/view/renderers/webviewThemeMapping'; +import { useServices } from 'vs/workbench/contrib/positronNotebook/browser/ServicesProvider'; +import { WebviewContentPurpose } from 'vs/workbench/contrib/webview/browser/webview'; + + +// Styles that get added to the HTML content of the webview for things like cleaning +// up tables etc.. +const htmlOutputStyles = ` +table { + width: 100%; + border-collapse: collapse; +} +table, th, td { + border: 1px solid #ddd; +} +th, td { + padding: 8px; + text-align: left; +} +tr:nth-child(even) { + background-color: var(--vscode-textBlockQuote-background, #f2f2f2); +} +`; + + +type HTMLOutputWebviewMessage = { + type: 'webviewMetrics'; + bodyScrollHeight: number; + bodyScrollWidth: number; +}; + +// No external imported code allowed. +// This function gets stringified and injected into the webview +// to send messages back to the Positron host. +function webviewMessageCode() { + const vscode = acquireVsCodeApi(); + // Send message on load back to Positron + // eslint-disable-next-line no-restricted-globals + window.onload = () => { + // Get body of the webview and measure content sizes + // eslint-disable-next-line no-restricted-syntax + const body = document.body; + const bodyScrollHeight = body.scrollHeight; + const bodyScrollWidth = body.scrollWidth; + + vscode.postMessage({ + type: 'webviewMetrics', + bodyScrollHeight, + bodyScrollWidth + }); + }; +} + +function isHTMLOutputWebviewMessage(message: any): message is HTMLOutputWebviewMessage { + return message?.type === 'webviewMetrics'; +} + +// Fake implementation for function that gets injected into the webview so we can get typing here. +function acquireVsCodeApi(): { postMessage: (message: HTMLOutputWebviewMessage) => void } { + throw new Error('Function not implemented.'); +} + + +export function NotebookHTMLContent({ content }: { content: string }) { + const { webviewService } = useServices(); + + const containerRef = React.useRef(null); + + React.useEffect(() => { + if (!containerRef.current) { + return; + } + + const webviewElement = webviewService.createWebviewElement({ + title: localize('positron.notebook.webview', "Positron Notebook HTML content"), + options: { + purpose: WebviewContentPurpose.NotebookRenderer, + enableFindWidget: false, + transformCssVariables: transformWebviewThemeVars, + }, + contentOptions: { + allowMultipleAPIAcquire: true, + allowScripts: true, + }, + extension: undefined, + providedViewType: 'notebook.output' + }); + + const contentWithStyles = buildWebviewHTML({ + content, + styles: htmlOutputStyles, + script: `(${webviewMessageCode.toString()})();` + }); + + webviewElement.setHtml(contentWithStyles); + webviewElement.onMessage(({ message }) => { + if (!isHTMLOutputWebviewMessage(message) || !containerRef.current) { return; } + // Set the height of the webview to the height of the content + // Don't allow the webview to be taller than 1000px + const boundedHeight = Math.min(message.bodyScrollHeight, 1000); + containerRef.current.style.height = `${boundedHeight}px`; + }); + webviewElement.mountTo(containerRef.current, getWindow(containerRef.current)); + return () => webviewElement.dispose(); + }, [content, webviewService]); + + return
; +} + +function buildWebviewHTML(opts: { + content: string; + styles?: string; + script?: string; +}): string { + + let all: string = opts.content; + + if (opts.styles) { + all = `` + all; + } + + if (opts.script) { + all = `` + all; + } + + return all; +} diff --git a/src/vs/workbench/services/positronNotebook/browser/IPositronNotebookCell.ts b/src/vs/workbench/services/positronNotebook/browser/IPositronNotebookCell.ts index 680dfe91529..eeb9cc09052 100644 --- a/src/vs/workbench/services/positronNotebook/browser/IPositronNotebookCell.ts +++ b/src/vs/workbench/services/positronNotebook/browser/IPositronNotebookCell.ts @@ -161,12 +161,14 @@ export enum CellKind { Code = 2 } +export type NotebookCellOutputItem = Readonly<{ + mime: string; + data: VSBuffer; +}>; + export interface NotebookCellOutputs { outputId: string; - outputs: { - readonly mime: string; - readonly data: VSBuffer; - }[]; + outputs: NotebookCellOutputItem[]; } /** diff --git a/src/vs/workbench/services/positronNotebook/browser/IPositronNotebookInstance.ts b/src/vs/workbench/services/positronNotebook/browser/IPositronNotebookInstance.ts index f749f6aef80..936f9606328 100644 --- a/src/vs/workbench/services/positronNotebook/browser/IPositronNotebookInstance.ts +++ b/src/vs/workbench/services/positronNotebook/browser/IPositronNotebookInstance.ts @@ -82,18 +82,6 @@ export interface IPositronNotebookInstance { */ deleteCell(cell?: IPositronNotebookCell): void; - /** - * Set the currently selected cells for notebook instance - * @param cellOrCells The cell or cells to set as selected - */ - setSelectedCells(cellOrCells: IPositronNotebookCell[]): void; - - /** - * Remove selection from cell - * @param cell The cell to deselect - */ - deselectCell(cell: IPositronNotebookCell): void; - /** * Set the currently editing cell. */