/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from 'vs/base/common/event'; import { Disposable, IDisposable, IReference, MutableDisposable, dispose } from 'vs/base/common/lifecycle'; import { Mimes } from 'vs/base/common/mime'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; import { IPosition } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { Selection } from 'vs/editor/common/core/selection'; import * as editorCommon from 'vs/editor/common/editorCommon'; import * as model from 'vs/editor/common/model'; import { SearchParams } from 'vs/editor/common/model/textModelSearch'; import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo'; import { IWordWrapTransientState, readTransientState, writeTransientState } from 'vs/workbench/contrib/codeEditor/browser/toggleWordWrap'; import { CellEditState, CellFocusMode, CursorAtBoundary, CursorAtLineBoundary, IEditableCellViewModel, INotebookCellDecorationOptions } from 'vs/workbench/contrib/notebook/browser/notebookBrowser'; import { NotebookOptionsChangeEvent } from 'vs/workbench/contrib/notebook/browser/notebookOptions'; import { CellViewModelStateChangeEvent } from 'vs/workbench/contrib/notebook/browser/notebookViewEvents'; import { ViewContext } from 'vs/workbench/contrib/notebook/browser/viewModel/viewContext'; import { NotebookCellTextModel } from 'vs/workbench/contrib/notebook/common/model/notebookCellTextModel'; import { CellKind, INotebookCellStatusBarItem, INotebookSearchOptions } from 'vs/workbench/contrib/notebook/common/notebookCommon'; export abstract class BaseCellViewModel extends Disposable { protected readonly _onDidChangeEditorAttachState = this._register(new Emitter()); // Do not merge this event with `onDidChangeState` as we are using `Event.once(onDidChangeEditorAttachState)` elsewhere. readonly onDidChangeEditorAttachState = this._onDidChangeEditorAttachState.event; protected readonly _onDidChangeState = this._register(new Emitter()); public readonly onDidChangeState: Event = this._onDidChangeState.event; get handle() { return this.model.handle; } get uri() { return this.model.uri; } get lineCount() { return this.model.textBuffer.getLineCount(); } get metadata() { return this.model.metadata; } get internalMetadata() { return this.model.internalMetadata; } get language() { return this.model.language; } get mime(): string { if (typeof this.model.mime === 'string') { return this.model.mime; } switch (this.language) { case 'markdown': return Mimes.markdown; default: return Mimes.text; } } abstract cellKind: CellKind; private _editState: CellEditState = CellEditState.Preview; private _lineNumbers: 'on' | 'off' | 'inherit' = 'inherit'; get lineNumbers(): 'on' | 'off' | 'inherit' { return this._lineNumbers; } set lineNumbers(lineNumbers: 'on' | 'off' | 'inherit') { if (lineNumbers === this._lineNumbers) { return; } this._lineNumbers = lineNumbers; this._onDidChangeState.fire({ cellLineNumberChanged: true }); } private _focusMode: CellFocusMode = CellFocusMode.Container; get focusMode() { return this._focusMode; } set focusMode(newMode: CellFocusMode) { if (this._focusMode !== newMode) { this._focusMode = newMode; this._onDidChangeState.fire({ focusModeChanged: true }); } } protected _textEditor?: ICodeEditor; get editorAttached(): boolean { return !!this._textEditor; } private _editorListeners: IDisposable[] = []; private _editorViewStates: editorCommon.ICodeEditorViewState | null = null; private _editorTransientState: IWordWrapTransientState | null = null; private _resolvedCellDecorations = new Map(); private _textModelRefChangeDisposable = this._register(new MutableDisposable()); private readonly _cellDecorationsChanged = this._register(new Emitter<{ added: INotebookCellDecorationOptions[]; removed: INotebookCellDecorationOptions[] }>()); onCellDecorationsChanged: Event<{ added: INotebookCellDecorationOptions[]; removed: INotebookCellDecorationOptions[] }> = this._cellDecorationsChanged.event; private _resolvedDecorations = new Map(); private _lastDecorationId: number = 0; private _cellStatusBarItems = new Map(); private readonly _onDidChangeCellStatusBarItems = this._register(new Emitter()); readonly onDidChangeCellStatusBarItems: Event = this._onDidChangeCellStatusBarItems.event; private _lastStatusBarId: number = 0; get textModel(): model.ITextModel | undefined { return this.model.textModel; } hasModel(): this is IEditableCellViewModel { return !!this.textModel; } private _dragging: boolean = false; get dragging(): boolean { return this._dragging; } set dragging(v: boolean) { this._dragging = v; this._onDidChangeState.fire({ dragStateChanged: true }); } protected _textModelRef: IReference | undefined; private _inputCollapsed: boolean = false; get isInputCollapsed(): boolean { return this._inputCollapsed; } set isInputCollapsed(v: boolean) { this._inputCollapsed = v; this._onDidChangeState.fire({ inputCollapsedChanged: true }); } private _outputCollapsed: boolean = false; get isOutputCollapsed(): boolean { return this._outputCollapsed; } set isOutputCollapsed(v: boolean) { this._outputCollapsed = v; this._onDidChangeState.fire({ outputCollapsedChanged: true }); } private _isDisposed = false; constructor( readonly viewType: string, readonly model: NotebookCellTextModel, public id: string, private readonly _viewContext: ViewContext, private readonly _configurationService: IConfigurationService, private readonly _modelService: ITextModelService, private readonly _undoRedoService: IUndoRedoService, private readonly _codeEditorService: ICodeEditorService, // private readonly _keymapService: INotebookKeymapService ) { super(); this._register(model.onDidChangeMetadata(() => { this._onDidChangeState.fire({ metadataChanged: true }); })); this._register(model.onDidChangeInternalMetadata(e => { this._onDidChangeState.fire({ internalMetadataChanged: true }); if (e.lastRunSuccessChanged) { // Statusbar visibility may change this.layoutChange({}); } })); this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('notebook.lineNumbers')) { this.lineNumbers = 'inherit'; } })); if (this.model.collapseState?.inputCollapsed) { this._inputCollapsed = true; } if (this.model.collapseState?.outputCollapsed) { this._outputCollapsed = true; } } abstract updateOptions(e: NotebookOptionsChangeEvent): void; abstract getHeight(lineHeight: number): number; abstract onDeselect(): void; abstract layoutChange(change: any): void; assertTextModelAttached(): boolean { if (this.textModel && this._textEditor && this._textEditor.getModel() === this.textModel) { return true; } return false; } // private handleKeyDown(e: IKeyboardEvent) { // if (this.viewType === IPYNB_VIEW_TYPE && isWindows && e.ctrlKey && e.keyCode === KeyCode.Enter) { // this._keymapService.promptKeymapRecommendation(); // } // } attachTextEditor(editor: ICodeEditor, estimatedHasHorizontalScrolling?: boolean) { if (!editor.hasModel()) { throw new Error('Invalid editor: model is missing'); } if (this._textEditor === editor) { if (this._editorListeners.length === 0) { this._editorListeners.push(this._textEditor.onDidChangeCursorSelection(() => { this._onDidChangeState.fire({ selectionChanged: true }); })); // this._editorListeners.push(this._textEditor.onKeyDown(e => this.handleKeyDown(e))); this._onDidChangeState.fire({ selectionChanged: true }); } return; } this._textEditor = editor; if (this._editorViewStates) { this._restoreViewState(this._editorViewStates); } else { // If no real editor view state was persisted, restore a default state. // This forces the editor to measure its content width immediately. if (estimatedHasHorizontalScrolling) { this._restoreViewState({ contributionsState: {}, cursorState: [], viewState: { scrollLeft: 0, firstPosition: { lineNumber: 1, column: 1 }, firstPositionDeltaTop: this._viewContext.notebookOptions.getLayoutConfiguration().editorTopPadding } }); } } if (this._editorTransientState) { writeTransientState(editor.getModel(), this._editorTransientState, this._codeEditorService); } this._textEditor?.changeDecorations((accessor) => { this._resolvedDecorations.forEach((value, key) => { if (key.startsWith('_lazy_')) { // lazy ones const ret = accessor.addDecoration(value.options.range, value.options.options); this._resolvedDecorations.get(key)!.id = ret; } else { const ret = accessor.addDecoration(value.options.range, value.options.options); this._resolvedDecorations.get(key)!.id = ret; } }); }); this._editorListeners.push(this._textEditor.onDidChangeCursorSelection(() => { this._onDidChangeState.fire({ selectionChanged: true }); })); // this._editorListeners.push(this._textEditor.onKeyDown(e => this.handleKeyDown(e))); this._onDidChangeState.fire({ selectionChanged: true }); this._onDidChangeEditorAttachState.fire(); } detachTextEditor() { this.saveViewState(); this.saveTransientState(); // decorations need to be cleared first as editors can be resued. this._textEditor?.changeDecorations((accessor) => { this._resolvedDecorations.forEach(value => { const resolvedid = value.id; if (resolvedid) { accessor.removeDecoration(resolvedid); } }); }); this._textEditor = undefined; dispose(this._editorListeners); this._editorListeners = []; this._onDidChangeEditorAttachState.fire(); if (this._textModelRef) { this._textModelRef.dispose(); this._textModelRef = undefined; } this._textModelRefChangeDisposable.clear(); } getText(): string { return this.model.getValue(); } getTextLength(): number { return this.model.getTextLength(); } private saveViewState(): void { if (!this._textEditor) { return; } this._editorViewStates = this._textEditor.saveViewState(); } private saveTransientState() { if (!this._textEditor || !this._textEditor.hasModel()) { return; } this._editorTransientState = readTransientState(this._textEditor.getModel(), this._codeEditorService); } saveEditorViewState() { if (this._textEditor) { this._editorViewStates = this._textEditor.saveViewState(); } return this._editorViewStates; } restoreEditorViewState(editorViewStates: editorCommon.ICodeEditorViewState | null, totalHeight?: number) { this._editorViewStates = editorViewStates; } private _restoreViewState(state: editorCommon.ICodeEditorViewState | null): void { if (state) { this._textEditor?.restoreViewState(state); } } addModelDecoration(decoration: model.IModelDeltaDecoration): string { if (!this._textEditor) { const id = ++this._lastDecorationId; const decorationId = `_lazy_${this.id};${id}`; this._resolvedDecorations.set(decorationId, { options: decoration }); return decorationId; } let id: string; this._textEditor.changeDecorations((accessor) => { id = accessor.addDecoration(decoration.range, decoration.options); this._resolvedDecorations.set(id, { id, options: decoration }); }); return id!; } removeModelDecoration(decorationId: string) { const realDecorationId = this._resolvedDecorations.get(decorationId); if (this._textEditor && realDecorationId && realDecorationId.id !== undefined) { this._textEditor.changeDecorations((accessor) => { accessor.removeDecoration(realDecorationId.id!); }); } // lastly, remove all the cache this._resolvedDecorations.delete(decorationId); } deltaModelDecorations(oldDecorations: readonly string[], newDecorations: readonly model.IModelDeltaDecoration[]): string[] { oldDecorations.forEach(id => { this.removeModelDecoration(id); }); const ret = newDecorations.map(option => { return this.addModelDecoration(option); }); return ret; } private _removeCellDecoration(decorationId: string) { const options = this._resolvedCellDecorations.get(decorationId); this._resolvedCellDecorations.delete(decorationId); if (options) { for (const existingOptions of this._resolvedCellDecorations.values()) { // don't remove decorations that are applied from other entries if (options.className === existingOptions.className) { options.className = undefined; } if (options.outputClassName === existingOptions.outputClassName) { options.outputClassName = undefined; } if (options.gutterClassName === existingOptions.gutterClassName) { options.gutterClassName = undefined; } if (options.topClassName === existingOptions.topClassName) { options.topClassName = undefined; } } this._cellDecorationsChanged.fire({ added: [], removed: [options] }); } } private _addCellDecoration(options: INotebookCellDecorationOptions): string { const id = ++this._lastDecorationId; const decorationId = `_cell_${this.id};${id}`; this._resolvedCellDecorations.set(decorationId, options); this._cellDecorationsChanged.fire({ added: [options], removed: [] }); return decorationId; } getCellDecorations() { return [...this._resolvedCellDecorations.values()]; } getCellDecorationRange(decorationId: string): Range | null { if (this._textEditor) { // (this._textEditor as CodeEditorWidget).decora return this._textEditor.getModel()?.getDecorationRange(decorationId) ?? null; } return null; } deltaCellDecorations(oldDecorations: string[], newDecorations: INotebookCellDecorationOptions[]): string[] { oldDecorations.forEach(id => { this._removeCellDecoration(id); }); const ret = newDecorations.map(option => { return this._addCellDecoration(option); }); return ret; } deltaCellStatusBarItems(oldItems: readonly string[], newItems: readonly INotebookCellStatusBarItem[]): string[] { oldItems.forEach(id => { const item = this._cellStatusBarItems.get(id); if (item) { this._cellStatusBarItems.delete(id); } }); const newIds = newItems.map(item => { const id = ++this._lastStatusBarId; const itemId = `_cell_${this.id};${id}`; this._cellStatusBarItems.set(itemId, item); return itemId; }); this._onDidChangeCellStatusBarItems.fire(); return newIds; } getCellStatusBarItems(): INotebookCellStatusBarItem[] { return Array.from(this._cellStatusBarItems.values()); } revealRangeInCenter(range: Range) { this._textEditor?.revealRangeInCenter(range, editorCommon.ScrollType.Immediate); } setSelection(range: Range) { this._textEditor?.setSelection(range); } setSelections(selections: Selection[]) { if (selections.length) { this._textEditor?.setSelections(selections); } } getSelections() { return this._textEditor?.getSelections() || []; } getSelectionsStartPosition(): IPosition[] | undefined { if (this._textEditor) { const selections = this._textEditor.getSelections(); return selections?.map(s => s.getStartPosition()); } else { const selections = this._editorViewStates?.cursorState; return selections?.map(s => s.selectionStart); } } getLineScrollTopOffset(line: number): number { if (!this._textEditor) { return 0; } const editorPadding = this._viewContext.notebookOptions.computeEditorPadding(this.internalMetadata, this.uri); return this._textEditor.getTopForLineNumber(line) + editorPadding.top; } getPositionScrollTopOffset(range: Selection | Range): number { if (!this._textEditor) { return 0; } const position = range instanceof Selection ? range.getPosition() : range.getStartPosition(); const editorPadding = this._viewContext.notebookOptions.computeEditorPadding(this.internalMetadata, this.uri); return this._textEditor.getTopForPosition(position.lineNumber, position.column) + editorPadding.top; } cursorAtLineBoundary(): CursorAtLineBoundary { if (!this._textEditor || !this.textModel || !this._textEditor.hasTextFocus()) { return CursorAtLineBoundary.None; } const selection = this._textEditor.getSelection(); if (!selection || !selection.isEmpty()) { return CursorAtLineBoundary.None; } const currentLineLength = this.textModel.getLineLength(selection.startLineNumber); if (currentLineLength === 0) { return CursorAtLineBoundary.Both; } switch (selection.startColumn) { case 1: return CursorAtLineBoundary.Start; case currentLineLength + 1: return CursorAtLineBoundary.End; default: return CursorAtLineBoundary.None; } } cursorAtBoundary(): CursorAtBoundary { if (!this._textEditor) { return CursorAtBoundary.None; } if (!this.textModel) { return CursorAtBoundary.None; } // only validate primary cursor const selection = this._textEditor.getSelection(); // only validate empty cursor if (!selection || !selection.isEmpty()) { return CursorAtBoundary.None; } const firstViewLineTop = this._textEditor.getTopForPosition(1, 1); const lastViewLineTop = this._textEditor.getTopForPosition(this.textModel.getLineCount(), this.textModel.getLineLength(this.textModel.getLineCount())); const selectionTop = this._textEditor.getTopForPosition(selection.startLineNumber, selection.startColumn); if (selectionTop === lastViewLineTop) { if (selectionTop === firstViewLineTop) { return CursorAtBoundary.Both; } else { return CursorAtBoundary.Bottom; } } else { if (selectionTop === firstViewLineTop) { return CursorAtBoundary.Top; } else { return CursorAtBoundary.None; } } } private _editStateSource: string = ''; get editStateSource(): string { return this._editStateSource; } updateEditState(newState: CellEditState, source: string) { this._editStateSource = source; if (newState === this._editState) { return; } this._editState = newState; this._onDidChangeState.fire({ editStateChanged: true }); if (this._editState === CellEditState.Preview) { this.focusMode = CellFocusMode.Container; } } getEditState() { return this._editState; } get textBuffer() { return this.model.textBuffer; } /** * Text model is used for editing. */ async resolveTextModel(): Promise { if (!this._textModelRef || !this.textModel) { this._textModelRef = await this._modelService.createModelReference(this.uri); if (this._isDisposed) { return this.textModel!; } if (!this._textModelRef) { throw new Error(`Cannot resolve text model for ${this.uri}`); } this._textModelRefChangeDisposable.value = this.textModel!.onDidChangeContent(() => this.onDidChangeTextModelContent()); } return this.textModel!; } protected abstract onDidChangeTextModelContent(): void; protected cellStartFind(value: string, options: INotebookSearchOptions): model.FindMatch[] | null { let cellMatches: model.FindMatch[] = []; if (this.assertTextModelAttached()) { cellMatches = this.textModel!.findMatches( value, false, options.regex || false, options.caseSensitive || false, options.wholeWord ? options.wordSeparators || null : null, options.regex || false); } else { const lineCount = this.textBuffer.getLineCount(); const fullRange = new Range(1, 1, lineCount, this.textBuffer.getLineLength(lineCount) + 1); const searchParams = new SearchParams(value, options.regex || false, options.caseSensitive || false, options.wholeWord ? options.wordSeparators || null : null,); const searchData = searchParams.parseSearchRequest(); if (!searchData) { return null; } cellMatches = this.textBuffer.findMatchesLineByLine(fullRange, searchData, options.regex || false, 1000); } return cellMatches; } override dispose() { this._isDisposed = true; super.dispose(); dispose(this._editorListeners); // Only remove the undo redo stack if we map this cell uri to itself // If we are not in perCell mode, it will map to the full NotebookDocument and // we don't want to remove that entire document undo / redo stack when a cell is deleted if (this._undoRedoService.getUriComparisonKey(this.uri) === this.uri.toString()) { this._undoRedoService.removeElements(this.uri); } this._textModelRef?.dispose(); } toJSON(): object { return { handle: this.handle }; } }