Path: blob/main/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffViewModel.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../../base/common/cancellation.js';6import { IDiffResult } from '../../../../../base/common/diff/diff.js';7import { Emitter, type IValueWithChangeEvent } from '../../../../../base/common/event.js';8import { Disposable, DisposableStore, dispose } from '../../../../../base/common/lifecycle.js';9import { Schemas } from '../../../../../base/common/network.js';10import type { URI } from '../../../../../base/common/uri.js';11import { FontInfo } from '../../../../../editor/common/config/fontInfo.js';12import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';13import type { ContextKeyValue } from '../../../../../platform/contextkey/common/contextkey.js';14import { MultiDiffEditorItem } from '../../../multiDiffEditor/browser/multiDiffSourceResolverService.js';15import { DiffElementCellViewModelBase, DiffElementPlaceholderViewModel, IDiffElementViewModelBase, NotebookDocumentMetadataViewModel, SideBySideDiffElementViewModel, SingleSideDiffElementViewModel } from './diffElementViewModel.js';16import { NotebookDiffEditorEventDispatcher } from './eventDispatcher.js';17import { INotebookDiffViewModel, INotebookDiffViewModelUpdateEvent, NOTEBOOK_DIFF_ITEM_DIFF_STATE, NOTEBOOK_DIFF_ITEM_KIND } from './notebookDiffEditorBrowser.js';18import { NotebookTextModel } from '../../common/model/notebookTextModel.js';19import { CellUri, INotebookDiffEditorModel } from '../../common/notebookCommon.js';20import { INotebookService } from '../../common/notebookService.js';21import { INotebookEditorWorkerService } from '../../common/services/notebookWorkerService.js';22import { IDiffEditorHeightCalculatorService } from './editorHeightCalculator.js';23import { raceCancellation } from '../../../../../base/common/async.js';24import { computeDiff } from '../../common/notebookDiff.js';2526export class NotebookDiffViewModel extends Disposable implements INotebookDiffViewModel, IValueWithChangeEvent<readonly MultiDiffEditorItem[]> {27private readonly placeholderAndRelatedCells = new Map<DiffElementPlaceholderViewModel, DiffElementCellViewModelBase[]>();28private readonly _items: IDiffElementViewModelBase[] = [];29get items(): readonly IDiffElementViewModelBase[] {30return this._items;31}32private readonly _onDidChangeItems = this._register(new Emitter<INotebookDiffViewModelUpdateEvent>());33public readonly onDidChangeItems = this._onDidChangeItems.event;34private readonly disposables = this._register(new DisposableStore());35private _onDidChange = this._register(new Emitter<void>());36private diffEditorItems: NotebookMultiDiffEditorItem[] = [];37public onDidChange = this._onDidChange.event;38private notebookMetadataViewModel?: NotebookDocumentMetadataViewModel;39get value(): readonly NotebookMultiDiffEditorItem[] {40return this.diffEditorItems41.filter(item => item.type !== 'placeholder')42.filter(item => {43if (this._includeUnchanged) {44return true;45}46if (item instanceof NotebookMultiDiffEditorCellItem) {47return item.type === 'unchanged' && item.containerType === 'unchanged' ? false : true;48}49if (item instanceof NotebookMultiDiffEditorMetadataItem) {50return item.type === 'unchanged' && item.containerType === 'unchanged' ? false : true;51}52if (item instanceof NotebookMultiDiffEditorOutputItem) {53return item.type === 'unchanged' && item.containerType === 'unchanged' ? false : true;54}55return true;56})57.filter(item => item instanceof NotebookMultiDiffEditorOutputItem ? !this.hideOutput : true)58.filter(item => item instanceof NotebookMultiDiffEditorMetadataItem ? !this.ignoreMetadata : true);59}6061private _hasUnchangedCells?: boolean;62public get hasUnchangedCells() {63return this._hasUnchangedCells === true;64}65private _includeUnchanged?: boolean;66public get includeUnchanged() {67return this._includeUnchanged === true;68}69public set includeUnchanged(value) {70this._includeUnchanged = value;71this._onDidChange.fire();72}73private hideOutput?: boolean;74private ignoreMetadata?: boolean;7576private originalCellViewModels: IDiffElementViewModelBase[] = [];77constructor(private readonly model: INotebookDiffEditorModel,78private readonly notebookEditorWorkerService: INotebookEditorWorkerService,79private readonly configurationService: IConfigurationService,80private readonly eventDispatcher: NotebookDiffEditorEventDispatcher,81private readonly notebookService: INotebookService,82private readonly diffEditorHeightCalculator: IDiffEditorHeightCalculatorService,83private readonly fontInfo?: FontInfo,84private readonly excludeUnchangedPlaceholder?: boolean,85) {86super();87this.hideOutput = this.model.modified.notebook.transientOptions.transientOutputs || this.configurationService.getValue<boolean>('notebook.diff.ignoreOutputs');88this.ignoreMetadata = this.configurationService.getValue('notebook.diff.ignoreMetadata');8990this._register(this.configurationService.onDidChangeConfiguration(e => {91let triggerChange = false;92let metadataChanged = false;93if (e.affectsConfiguration('notebook.diff.ignoreMetadata')) {94const newValue = this.configurationService.getValue<boolean>('notebook.diff.ignoreMetadata');9596if (newValue !== undefined && this.ignoreMetadata !== newValue) {97this.ignoreMetadata = newValue;98triggerChange = true;99metadataChanged = true;100}101}102103if (e.affectsConfiguration('notebook.diff.ignoreOutputs')) {104const newValue = this.configurationService.getValue<boolean>('notebook.diff.ignoreOutputs');105106if (newValue !== undefined && this.hideOutput !== (newValue || this.model.modified.notebook.transientOptions.transientOutputs)) {107this.hideOutput = newValue || !!(this.model.modified.notebook.transientOptions.transientOutputs);108triggerChange = true;109}110}111112if (metadataChanged) {113this.toggleNotebookMetadata();114}115if (triggerChange) {116this._onDidChange.fire();117}118}));119}120override dispose() {121this.clear();122super.dispose();123}124private clear() {125this.disposables.clear();126dispose(Array.from(this.placeholderAndRelatedCells.keys()));127this.placeholderAndRelatedCells.clear();128dispose(this.originalCellViewModels);129this.originalCellViewModels = [];130dispose(this._items);131this._items.splice(0, this._items.length);132}133134async computeDiff(token: CancellationToken): Promise<void> {135const diffResult = await raceCancellation(this.notebookEditorWorkerService.computeDiff(this.model.original.resource, this.model.modified.resource), token);136if (!diffResult || token.isCancellationRequested) {137// after await the editor might be disposed.138return;139}140141prettyChanges(this.model.original.notebook, this.model.modified.notebook, diffResult.cellsDiff);142143const { cellDiffInfo, firstChangeIndex } = computeDiff(this.model.original.notebook, this.model.modified.notebook, diffResult);144if (isEqual(cellDiffInfo, this.originalCellViewModels, this.model)) {145return;146} else {147await raceCancellation(this.updateViewModels(cellDiffInfo, diffResult.metadataChanged, firstChangeIndex), token);148if (token.isCancellationRequested) {149return;150}151this.updateDiffEditorItems();152}153}154155private toggleNotebookMetadata() {156if (!this.notebookMetadataViewModel) {157return;158}159160if (this.ignoreMetadata) {161if (this._items.length && this._items[0] === this.notebookMetadataViewModel) {162this._items.splice(0, 1);163this._onDidChangeItems.fire({ start: 0, deleteCount: 1, elements: [] });164}165} else {166if (!this._items.length || this._items[0] !== this.notebookMetadataViewModel) {167this._items.splice(0, 0, this.notebookMetadataViewModel);168this._onDidChangeItems.fire({ start: 0, deleteCount: 0, elements: [this.notebookMetadataViewModel] });169}170}171}172private updateDiffEditorItems() {173this.diffEditorItems = [];174const originalSourceUri = this.model.original.resource!;175const modifiedSourceUri = this.model.modified.resource!;176this._hasUnchangedCells = false;177this.items.forEach(item => {178switch (item.type) {179case 'delete': {180this.diffEditorItems.push(new NotebookMultiDiffEditorCellItem(item.original!.uri, undefined, item.type, item.type));181const originalMetadata = CellUri.generateCellPropertyUri(originalSourceUri, item.original!.handle, Schemas.vscodeNotebookCellMetadata);182this.diffEditorItems.push(new NotebookMultiDiffEditorMetadataItem(originalMetadata, undefined, item.type, item.type));183const originalOutput = CellUri.generateCellPropertyUri(originalSourceUri, item.original!.handle, Schemas.vscodeNotebookCellOutput);184this.diffEditorItems.push(new NotebookMultiDiffEditorOutputItem(originalOutput, undefined, item.type, item.type));185break;186}187case 'insert': {188this.diffEditorItems.push(new NotebookMultiDiffEditorCellItem(undefined, item.modified!.uri, item.type, item.type));189const modifiedMetadata = CellUri.generateCellPropertyUri(modifiedSourceUri, item.modified!.handle, Schemas.vscodeNotebookCellMetadata);190this.diffEditorItems.push(new NotebookMultiDiffEditorMetadataItem(undefined, modifiedMetadata, item.type, item.type));191const modifiedOutput = CellUri.generateCellPropertyUri(modifiedSourceUri, item.modified!.handle, Schemas.vscodeNotebookCellOutput);192this.diffEditorItems.push(new NotebookMultiDiffEditorOutputItem(undefined, modifiedOutput, item.type, item.type));193break;194}195case 'modified': {196const cellType = item.checkIfInputModified() ? item.type : 'unchanged';197const containerChanged = (item.checkIfInputModified() || item.checkMetadataIfModified() || item.checkIfOutputsModified()) ? item.type : 'unchanged';198this.diffEditorItems.push(new NotebookMultiDiffEditorCellItem(item.original!.uri, item.modified!.uri, cellType, containerChanged));199const originalMetadata = CellUri.generateCellPropertyUri(originalSourceUri, item.original!.handle, Schemas.vscodeNotebookCellMetadata);200const modifiedMetadata = CellUri.generateCellPropertyUri(modifiedSourceUri, item.modified!.handle, Schemas.vscodeNotebookCellMetadata);201this.diffEditorItems.push(new NotebookMultiDiffEditorMetadataItem(originalMetadata, modifiedMetadata, item.checkMetadataIfModified() ? item.type : 'unchanged', containerChanged));202const originalOutput = CellUri.generateCellPropertyUri(originalSourceUri, item.original!.handle, Schemas.vscodeNotebookCellOutput);203const modifiedOutput = CellUri.generateCellPropertyUri(modifiedSourceUri, item.modified!.handle, Schemas.vscodeNotebookCellOutput);204this.diffEditorItems.push(new NotebookMultiDiffEditorOutputItem(originalOutput, modifiedOutput, item.checkIfOutputsModified() ? item.type : 'unchanged', containerChanged));205break;206}207case 'unchanged': {208this._hasUnchangedCells = true;209this.diffEditorItems.push(new NotebookMultiDiffEditorCellItem(item.original!.uri, item.modified!.uri, item.type, item.type));210const originalMetadata = CellUri.generateCellPropertyUri(originalSourceUri, item.original!.handle, Schemas.vscodeNotebookCellMetadata);211const modifiedMetadata = CellUri.generateCellPropertyUri(modifiedSourceUri, item.modified!.handle, Schemas.vscodeNotebookCellMetadata);212this.diffEditorItems.push(new NotebookMultiDiffEditorMetadataItem(originalMetadata, modifiedMetadata, item.type, item.type));213const originalOutput = CellUri.generateCellPropertyUri(originalSourceUri, item.original!.handle, Schemas.vscodeNotebookCellOutput);214const modifiedOutput = CellUri.generateCellPropertyUri(modifiedSourceUri, item.modified!.handle, Schemas.vscodeNotebookCellOutput);215this.diffEditorItems.push(new NotebookMultiDiffEditorOutputItem(originalOutput, modifiedOutput, item.type, item.type));216break;217}218}219});220221this._onDidChange.fire();222}223224private async updateViewModels(cellDiffInfo: CellDiffInfo[], metadataChanged: boolean, firstChangeIndex: number) {225const cellViewModels = await this.createDiffViewModels(cellDiffInfo, metadataChanged);226const oldLength = this._items.length;227this.clear();228this._items.splice(0, oldLength);229230let placeholder: DiffElementPlaceholderViewModel | undefined = undefined;231this.originalCellViewModels = cellViewModels;232cellViewModels.forEach((vm, index) => {233if (vm.type === 'unchanged' && !this.excludeUnchangedPlaceholder) {234if (!placeholder) {235vm.displayIconToHideUnmodifiedCells = true;236placeholder = new DiffElementPlaceholderViewModel(vm.mainDocumentTextModel, vm.editorEventDispatcher, vm.initData);237this._items.push(placeholder);238const placeholderItem = placeholder;239240this.disposables.add(placeholderItem.onUnfoldHiddenCells(() => {241const hiddenCellViewModels = this.placeholderAndRelatedCells.get(placeholderItem);242if (!Array.isArray(hiddenCellViewModels)) {243return;244}245const start = this._items.indexOf(placeholderItem);246this._items.splice(start, 1, ...hiddenCellViewModels);247this._onDidChangeItems.fire({ start, deleteCount: 1, elements: hiddenCellViewModels });248}));249this.disposables.add(vm.onHideUnchangedCells(() => {250const hiddenCellViewModels = this.placeholderAndRelatedCells.get(placeholderItem);251if (!Array.isArray(hiddenCellViewModels)) {252return;253}254const start = this._items.indexOf(vm);255this._items.splice(start, hiddenCellViewModels.length, placeholderItem);256this._onDidChangeItems.fire({ start, deleteCount: hiddenCellViewModels.length, elements: [placeholderItem] });257}));258}259const hiddenCellViewModels = this.placeholderAndRelatedCells.get(placeholder) || [];260hiddenCellViewModels.push(vm);261this.placeholderAndRelatedCells.set(placeholder, hiddenCellViewModels);262placeholder.hiddenCells.push(vm);263} else {264placeholder = undefined;265this._items.push(vm);266}267});268269// Note, ensure all of the height calculations are done before firing the event.270// This is to ensure that the diff editor is not resized multiple times, thereby avoiding flickering.271this._onDidChangeItems.fire({ start: 0, deleteCount: oldLength, elements: this._items, firstChangeIndex });272}273private async createDiffViewModels(computedCellDiffs: CellDiffInfo[], metadataChanged: boolean) {274const originalModel = this.model.original.notebook;275const modifiedModel = this.model.modified.notebook;276const initData = {277metadataStatusHeight: this.configurationService.getValue('notebook.diff.ignoreMetadata') ? 0 : 25,278outputStatusHeight: this.configurationService.getValue<boolean>('notebook.diff.ignoreOutputs') || !!(modifiedModel.transientOptions.transientOutputs) ? 0 : 25,279fontInfo: this.fontInfo280};281282const viewModels: (SingleSideDiffElementViewModel | SideBySideDiffElementViewModel | NotebookDocumentMetadataViewModel)[] = [];283this.notebookMetadataViewModel = this._register(new NotebookDocumentMetadataViewModel(this.model.original.notebook, this.model.modified.notebook, metadataChanged ? 'modifiedMetadata' : 'unchangedMetadata', this.eventDispatcher, initData, this.notebookService, this.diffEditorHeightCalculator));284if (!this.ignoreMetadata) {285if (metadataChanged) {286await this.notebookMetadataViewModel.computeHeights();287}288viewModels.push(this.notebookMetadataViewModel);289}290const cellViewModels = await Promise.all(computedCellDiffs.map(async (diff) => {291switch (diff.type) {292case 'delete': {293return new SingleSideDiffElementViewModel(294originalModel,295modifiedModel,296originalModel.cells[diff.originalCellIndex],297undefined,298'delete',299this.eventDispatcher,300initData,301this.notebookService,302this.configurationService,303this.diffEditorHeightCalculator,304diff.originalCellIndex305);306}307case 'insert': {308return new SingleSideDiffElementViewModel(309modifiedModel,310originalModel,311undefined,312modifiedModel.cells[diff.modifiedCellIndex],313'insert',314this.eventDispatcher,315initData,316this.notebookService,317this.configurationService,318this.diffEditorHeightCalculator,319diff.modifiedCellIndex320);321}322case 'modified': {323const viewModel = new SideBySideDiffElementViewModel(324this.model.modified.notebook,325this.model.original.notebook,326originalModel.cells[diff.originalCellIndex],327modifiedModel.cells[diff.modifiedCellIndex],328'modified',329this.eventDispatcher,330initData,331this.notebookService,332this.configurationService,333diff.originalCellIndex,334this.diffEditorHeightCalculator335);336// Reduces flicker (compute this before setting the model)337// Else when the model is set, the height of the editor will be x, after diff is computed, then height will be y.338// & that results in flicker.339await viewModel.computeEditorHeights();340return viewModel;341}342case 'unchanged': {343return new SideBySideDiffElementViewModel(344this.model.modified.notebook,345this.model.original.notebook,346originalModel.cells[diff.originalCellIndex],347modifiedModel.cells[diff.modifiedCellIndex],348'unchanged', this.eventDispatcher,349initData,350this.notebookService,351this.configurationService,352diff.originalCellIndex,353this.diffEditorHeightCalculator354);355}356}357}));358359cellViewModels.forEach(vm => viewModels.push(vm));360361return viewModels;362}363364}365366367/**368* making sure that swapping cells are always translated to `insert+delete`.369*/370export function prettyChanges(original: NotebookTextModel, modified: NotebookTextModel, diffResult: IDiffResult) {371const changes = diffResult.changes;372for (let i = 0; i < diffResult.changes.length - 1; i++) {373// then we know there is another change after current one374const curr = changes[i];375const next = changes[i + 1];376const x = curr.originalStart;377const y = curr.modifiedStart;378379if (380curr.originalLength === 1381&& curr.modifiedLength === 0382&& next.originalStart === x + 2383&& next.originalLength === 0384&& next.modifiedStart === y + 1385&& next.modifiedLength === 1386&& original.cells[x].getHashValue() === modified.cells[y + 1].getHashValue()387&& original.cells[x + 1].getHashValue() === modified.cells[y].getHashValue()388) {389// this is a swap390curr.originalStart = x;391curr.originalLength = 0;392curr.modifiedStart = y;393curr.modifiedLength = 1;394395next.originalStart = x + 1;396next.originalLength = 1;397next.modifiedStart = y + 2;398next.modifiedLength = 0;399400i++;401}402}403}404405export type CellDiffInfo = {406originalCellIndex: number;407modifiedCellIndex: number;408type: 'unchanged' | 'modified';409} |410{411originalCellIndex: number;412type: 'delete';413} |414{415modifiedCellIndex: number;416type: 'insert';417};418419function isEqual(cellDiffInfo: CellDiffInfo[], viewModels: IDiffElementViewModelBase[], model: INotebookDiffEditorModel) {420if (cellDiffInfo.length !== viewModels.length) {421return false;422}423const originalModel = model.original.notebook;424const modifiedModel = model.modified.notebook;425for (let i = 0; i < viewModels.length; i++) {426const a = cellDiffInfo[i];427const b = viewModels[i];428if (a.type !== b.type) {429return false;430}431switch (a.type) {432case 'delete': {433if (originalModel.cells[a.originalCellIndex].handle !== b.original?.handle) {434return false;435}436continue;437}438case 'insert': {439if (modifiedModel.cells[a.modifiedCellIndex].handle !== b.modified?.handle) {440return false;441}442continue;443}444default: {445if (originalModel.cells[a.originalCellIndex].handle !== b.original?.handle) {446return false;447}448if (modifiedModel.cells[a.modifiedCellIndex].handle !== b.modified?.handle) {449return false;450}451continue;452}453}454}455456return true;457}458export abstract class NotebookMultiDiffEditorItem extends MultiDiffEditorItem {459constructor(460originalUri: URI | undefined,461modifiedUri: URI | undefined,462goToFileUri: URI | undefined,463public readonly type: IDiffElementViewModelBase['type'],464public readonly containerType: IDiffElementViewModelBase['type'],465public kind: 'Cell' | 'Metadata' | 'Output',466contextKeys?: Record<string, ContextKeyValue>,467) {468super(originalUri, modifiedUri, goToFileUri, undefined, contextKeys);469}470}471472class NotebookMultiDiffEditorCellItem extends NotebookMultiDiffEditorItem {473constructor(474originalUri: URI | undefined,475modifiedUri: URI | undefined,476type: IDiffElementViewModelBase['type'],477containerType: IDiffElementViewModelBase['type'],478) {479super(originalUri, modifiedUri, modifiedUri || originalUri, type, containerType, 'Cell', {480[NOTEBOOK_DIFF_ITEM_KIND.key]: 'Cell',481[NOTEBOOK_DIFF_ITEM_DIFF_STATE.key]: type482});483}484}485486class NotebookMultiDiffEditorMetadataItem extends NotebookMultiDiffEditorItem {487constructor(488originalUri: URI | undefined,489modifiedUri: URI | undefined,490type: IDiffElementViewModelBase['type'],491containerType: IDiffElementViewModelBase['type'],492) {493super(originalUri, modifiedUri, modifiedUri || originalUri, type, containerType, 'Metadata', {494[NOTEBOOK_DIFF_ITEM_KIND.key]: 'Metadata',495[NOTEBOOK_DIFF_ITEM_DIFF_STATE.key]: type496});497}498}499500class NotebookMultiDiffEditorOutputItem extends NotebookMultiDiffEditorItem {501constructor(502originalUri: URI | undefined,503modifiedUri: URI | undefined,504type: IDiffElementViewModelBase['type'],505containerType: IDiffElementViewModelBase['type'],506) {507super(originalUri, modifiedUri, modifiedUri || originalUri, type, containerType, 'Output', {508[NOTEBOOK_DIFF_ITEM_KIND.key]: 'Output',509[NOTEBOOK_DIFF_ITEM_DIFF_STATE.key]: type510});511}512}513514515