Files
vscode/src/vs/workbench/contrib/notebook/browser/viewModel/baseCellViewModel.ts
T
Simon SiefkeandGitHub ea142b5ccd fix: memory leak in notebook baseCellViewModel (#205499)
* fix: memory leak in notebook baseCellViewModel

* fix: dispose when detaching text editor

* use mutabledisposable
2024-03-05 19:15:43 -08:00

680 lines
21 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* 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<void>());
// 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<CellViewModelStateChangeEvent>());
public readonly onDidChangeState: Event<CellViewModelStateChangeEvent> = 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<string, INotebookCellDecorationOptions>();
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<string, {
id?: string;
options: model.IModelDeltaDecoration;
}>();
private _lastDecorationId: number = 0;
private _cellStatusBarItems = new Map<string, INotebookCellStatusBarItem>();
private readonly _onDidChangeCellStatusBarItems = this._register(new Emitter<void>());
readonly onDidChangeCellStatusBarItems: Event<void> = 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<IResolvedTextEditorModel> | 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<model.ITextModel> {
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
};
}
}