From aa88e727da3dcb58b112085afa466b5e43ab1c1b Mon Sep 17 00:00:00 2001 From: Henning Dieterichs Date: Mon, 5 Jun 2023 17:50:55 +0200 Subject: [PATCH] Implements initial version of moved code detection. (#184336) * Implements initial version of moved code detection. * Fixes monaco.d.ts * Fixes tests. --- .../lib/stylelint/vscode-known-variables.json | 1 + build/monaco/monaco.d.ts.recipe | 2 +- .../browser/services/editorWorkerService.ts | 49 ++-- .../editor/browser/widget/diffEditorWidget.ts | 1 + .../widget/diffEditorWidget2/colors.ts | 13 + .../diffEditorWidget2.contribution.ts | 1 + .../diffEditorWidget2/diffEditorWidget2.ts | 161 ++++++++---- .../widget/diffEditorWidget2/diffModel.ts | 75 ++++-- .../widget/diffEditorWidget2/lineAlignment.ts | 230 ++++++++++++++---- .../diffEditorWidget2/movedBlocksLines.ts | 88 +++++++ .../diffEditorWidget2/overviewRulerPart.ts | 8 +- .../widget/diffEditorWidget2/style.css | 19 ++ .../diffEditorWidget2/unchangedRanges.ts | 2 +- .../browser/widget/diffEditorWidget2/utils.ts | 53 +++- .../widget/workerBasedDocumentDiffProvider.ts | 1 + .../config/editorConfigurationSchema.ts | 5 + src/vs/editor/common/config/editorOptions.ts | 4 + src/vs/editor/common/core/lineRange.ts | 18 ++ .../common/diff/documentDiffProvider.ts | 15 +- .../editor/common/diff/linesDiffComputer.ts | 40 ++- .../common/diff/smartLinesDiffComputer.ts | 4 +- .../common/diff/standardLinesDiffComputer.ts | 86 ++++++- .../common/services/editorSimpleWorker.ts | 25 +- src/vs/editor/common/services/editorWorker.ts | 9 + .../standalone/browser/standaloneEditor.ts | 4 +- .../services/editorSimpleWorker.test.ts | 2 +- .../test/node/diffing/diffingFixture.test.ts | 35 ++- .../test/node/diffing/fixtures/move-1/1.tst | 92 +++++++ .../test/node/diffing/fixtures/move-1/2.tst | 92 +++++++ .../move-1/advanced.expected.diff.json | 32 +++ .../fixtures/move-1/legacy.expected.diff.json | 22 ++ src/vs/monaco.d.ts | 36 ++- .../browser/interactiveEditorController.ts | 2 +- .../interactiveEditorLivePreviewWidget.ts | 8 +- .../browser/interactiveEditorSession.ts | 4 +- .../browser/interactiveEditorWidget.ts | 2 +- .../mergeEditor/browser/model/diffComputer.ts | 1 + .../mergeEditor/test/browser/model.test.ts | 2 +- 38 files changed, 1068 insertions(+), 176 deletions(-) create mode 100644 src/vs/editor/browser/widget/diffEditorWidget2/colors.ts create mode 100644 src/vs/editor/browser/widget/diffEditorWidget2/movedBlocksLines.ts create mode 100644 src/vs/editor/test/node/diffing/fixtures/move-1/1.tst create mode 100644 src/vs/editor/test/node/diffing/fixtures/move-1/2.tst create mode 100644 src/vs/editor/test/node/diffing/fixtures/move-1/advanced.expected.diff.json create mode 100644 src/vs/editor/test/node/diffing/fixtures/move-1/legacy.expected.diff.json diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index a52f14adcc6..1b72afa5a41 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -96,6 +96,7 @@ "--vscode-diffEditor-insertedLineBackground", "--vscode-diffEditor-insertedTextBackground", "--vscode-diffEditor-insertedTextBorder", + "--vscode-diffEditor-move-border", "--vscode-diffEditor-removedLineBackground", "--vscode-diffEditor-removedTextBackground", "--vscode-diffEditor-removedTextBorder", diff --git a/build/monaco/monaco.d.ts.recipe b/build/monaco/monaco.d.ts.recipe index be07371410b..de2cd6c28dc 100644 --- a/build/monaco/monaco.d.ts.recipe +++ b/build/monaco/monaco.d.ts.recipe @@ -110,7 +110,7 @@ export interface ICommandHandler { #include(vs/editor/common/diff/smartLinesDiffComputer): IChange, ICharChange, ILineChange #include(vs/editor/common/diff/documentDiffProvider): IDocumentDiffProvider, IDocumentDiffProviderOptions, IDocumentDiff #include(vs/editor/common/core/lineRange): LineRange -#include(vs/editor/common/diff/linesDiffComputer): LineRangeMapping, RangeMapping +#include(vs/editor/common/diff/linesDiffComputer): LineRangeMapping, RangeMapping, MovedText, SimpleLineRangeMapping #include(vs/editor/common/core/dimension): IDimension #includeAll(vs/editor/common/editorCommon): IScrollEvent #includeAll(vs/editor/common/textModelEvents): diff --git a/src/vs/editor/browser/services/editorWorkerService.ts b/src/vs/editor/browser/services/editorWorkerService.ts index eea0209a047..ba5739df58e 100644 --- a/src/vs/editor/browser/services/editorWorkerService.ts +++ b/src/vs/editor/browser/services/editorWorkerService.ts @@ -14,7 +14,7 @@ import { ITextModel } from 'vs/editor/common/model'; import * as languages from 'vs/editor/common/languages'; import { ILanguageConfigurationService } from 'vs/editor/common/languages/languageConfigurationRegistry'; import { EditorSimpleWorker } from 'vs/editor/common/services/editorSimpleWorker'; -import { DiffAlgorithmName, IDiffComputationResult, IEditorWorkerService, IUnicodeHighlightsResult } from 'vs/editor/common/services/editorWorker'; +import { DiffAlgorithmName, IDiffComputationResult, IEditorWorkerService, ILineChange, IUnicodeHighlightsResult } from 'vs/editor/common/services/editorWorker'; import { IModelService } from 'vs/editor/common/services/model'; import { ITextResourceConfigurationService } from 'vs/editor/common/services/textResourceConfiguration'; import { regExpFlags } from 'vs/base/common/strings'; @@ -27,7 +27,7 @@ import { IEditorWorkerHost } from 'vs/editor/common/services/editorWorkerHost'; import { ILanguageFeaturesService } from 'vs/editor/common/services/languageFeatures'; import { IChange } from 'vs/editor/common/diff/smartLinesDiffComputer'; import { IDocumentDiff, IDocumentDiffProviderOptions } from 'vs/editor/common/diff/documentDiffProvider'; -import { ILinesDiffComputerOptions, LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; +import { ILinesDiffComputerOptions, LineRangeMapping, MovedText, RangeMapping, SimpleLineRangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; import { LineRange } from 'vs/editor/common/core/lineRange'; /** @@ -106,22 +106,28 @@ export class EditorWorkerService extends Disposable implements IEditorWorkerServ const diff: IDocumentDiff = { identical: result.identical, quitEarly: result.quitEarly, - changes: result.changes.map( - (c) => - new LineRangeMapping( - new LineRange(c[0], c[1]), - new LineRange(c[2], c[3]), - c[4]?.map( - (c) => - new RangeMapping( - new Range(c[0], c[1], c[2], c[3]), - new Range(c[4], c[5], c[6], c[7]) - ) - ) - ) - ), + changes: toLineRangeMappings(result.changes), + moves: result.moves.map(m => new MovedText( + new SimpleLineRangeMapping(new LineRange(m[0], m[1]), new LineRange(m[2], m[3])), + toLineRangeMappings(m[4]) + )) }; return diff; + + function toLineRangeMappings(changes: readonly ILineChange[]): readonly LineRangeMapping[] { + return changes.map( + (c) => new LineRangeMapping( + new LineRange(c[0], c[1]), + new LineRange(c[2], c[3]), + c[4]?.map( + (c) => new RangeMapping( + new Range(c[0], c[1], c[2], c[3]), + new Range(c[4], c[5], c[6], c[7]) + ) + ) + ) + ); + } } public canComputeDirtyDiff(original: URI, modified: URI): boolean { @@ -153,11 +159,12 @@ export class EditorWorkerService extends Disposable implements IEditorWorkerServ return Promise.resolve(edits); // File too large } const sw = StopWatch.create(true); - const result = this._workerManager.withWorker().then(client => client.computeHumanReadableDiff(resource, edits, { ignoreTrimWhitespace: false, maxComputationTimeMs: 1000 })).catch((err) => { - onUnexpectedError(err); - // In case of an exception, fall back to computeMoreMinimalEdits - return this.computeMoreMinimalEdits(resource, edits, true); - }); + const result = this._workerManager.withWorker().then(client => client.computeHumanReadableDiff(resource, edits, + { ignoreTrimWhitespace: false, maxComputationTimeMs: 1000, computeMoves: false, })).catch((err) => { + onUnexpectedError(err); + // In case of an exception, fall back to computeMoreMinimalEdits + return this.computeMoreMinimalEdits(resource, edits, true); + }); result.finally(() => this._logService.trace('FORMAT#computeHumanReadableDiff', resource.toString(true), sw.elapsed())); return result; diff --git a/src/vs/editor/browser/widget/diffEditorWidget.ts b/src/vs/editor/browser/widget/diffEditorWidget.ts index 5ae574c076a..af4ac4c5eb4 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget.ts @@ -1167,6 +1167,7 @@ export class DiffEditorWidget extends Disposable implements editorBrowser.IDiffE this._documentDiffProvider.computeDiff(currentOriginalModel, currentModifiedModel, { ignoreTrimWhitespace: this._options.ignoreTrimWhitespace, maxComputationTimeMs: this._options.maxComputationTime, + computeMoves: false, }).then(result => { if (currentToken === this._diffComputationToken && currentOriginalModel === this._originalEditor.getModel() diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/colors.ts b/src/vs/editor/browser/widget/diffEditorWidget2/colors.ts new file mode 100644 index 00000000000..8c78445d74b --- /dev/null +++ b/src/vs/editor/browser/widget/diffEditorWidget2/colors.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from 'vs/nls'; +import { registerColor } from 'vs/platform/theme/common/colorRegistry'; + +export const diffMoveBorder = registerColor( + 'diffEditor.move.border', + { dark: '#8b8b8b9c', light: '#8b8b8b9c', hcDark: '#8b8b8b9c', hcLight: '#8b8b8b9c', }, + localize('diffEditor.move.border', 'The border color for text that got moved in the diff editor.') +); diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.contribution.ts b/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.contribution.ts index d81b2db5cf5..ca58d65aecd 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.contribution.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.contribution.ts @@ -10,6 +10,7 @@ import { localize } from 'vs/nls'; import { Action2, MenuId, MenuRegistry, registerAction2 } from 'vs/platform/actions/common/actions'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ContextKeyEqualsExpr, ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; +import './colors'; export class ToggleCollapseUnchangedRegions extends Action2 { constructor() { diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.ts b/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.ts index 1cde4ba424e..6722507b421 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget2/diffEditorWidget2.ts @@ -8,6 +8,8 @@ import { findLast } from 'vs/base/common/arrays'; import { onUnexpectedError } from 'vs/base/common/errors'; import { Emitter, Event } from 'vs/base/common/event'; import { IObservable, ISettableObservable, derived, keepAlive, observableValue, waitForState } from 'vs/base/common/observable'; +import { disposableObservableValue } from 'vs/base/common/observableImpl/base'; +import { isDefined } from 'vs/base/common/types'; import { Constants } from 'vs/base/common/uint'; import 'vs/css!./style'; import { IEditorConstructionOptions } from 'vs/editor/browser/config/editorConfiguration'; @@ -19,14 +21,15 @@ import { IDiffCodeEditorWidgetOptions } from 'vs/editor/browser/widget/diffEdito import { diffAddDecoration, diffDeleteDecoration, diffFullLineAddDecoration, diffFullLineDeleteDecoration } from 'vs/editor/browser/widget/diffEditorWidget2/decorations'; import { DiffEditorSash } from 'vs/editor/browser/widget/diffEditorWidget2/diffEditorSash'; import { ViewZoneAlignment } from 'vs/editor/browser/widget/diffEditorWidget2/lineAlignment'; +import { MovedBlocksLinesPart } from 'vs/editor/browser/widget/diffEditorWidget2/movedBlocksLines'; import { OverviewRulerPart } from 'vs/editor/browser/widget/diffEditorWidget2/overviewRulerPart'; import { UnchangedRangesFeature } from 'vs/editor/browser/widget/diffEditorWidget2/unchangedRanges'; import { ObservableElementSizeObserver, applyObservableDecorations } from 'vs/editor/browser/widget/diffEditorWidget2/utils'; import { WorkerBasedDocumentDiffProvider } from 'vs/editor/browser/widget/workerBasedDocumentDiffProvider'; import { EditorOptions, IDiffEditorOptions, ValidDiffEditorBaseOptions, clampedFloat, clampedInt, boolean as validateBooleanOption, stringSet as validateStringSetOption } from 'vs/editor/common/config/editorOptions'; import { IDimension } from 'vs/editor/common/core/dimension'; +import { LineRange } from 'vs/editor/common/core/lineRange'; import { Position } from 'vs/editor/common/core/position'; -import { LineRangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; import { IDiffComputationResult, ILineChange } from 'vs/editor/common/diff/smartLinesDiffComputer'; import { EditorType, IContentSizeChangedEvent, IDiffEditorModel, IDiffEditorViewState } from 'vs/editor/common/editorCommon'; import { IModelDeltaDecoration } from 'vs/editor/common/model'; @@ -35,7 +38,7 @@ import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { DelegatingEditor } from './delegatingEditorImpl'; -import { DiffModel } from './diffModel'; +import { DiffMapping, DiffModel } from './diffModel'; const diffEditorDefaultOptions: ValidDiffEditorBaseOptions = { enableSplitViewResizing: true, @@ -54,6 +57,7 @@ const diffEditorDefaultOptions: ValidDiffEditorBaseOptions = { accessibilityVerbose: false, experimental: { collapseUnchangedRegions: false, + showMoves: false, } }; @@ -64,7 +68,7 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { ]); private readonly _model = observableValue('diffEditorModel', null); public readonly onDidChangeModel = Event.fromObservableLight(this._model); - private readonly _diffModel = observableValue('diffModel', null); + private readonly _diffModel = this._register(disposableObservableValue('diffModel', undefined)); private readonly _onDidContentSizeChange = this._register(new Emitter()); public readonly onDidContentSizeChange = this._onDidContentSizeChange.event; private readonly _modifiedEditor: CodeEditorWidget; @@ -75,7 +79,6 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { ); private readonly _rootSizeObserver: ObservableElementSizeObserver; private readonly _options: ISettableObservable; - private _isHandlingScrollEvent = false; private readonly _sash: DiffEditorSash; private readonly _renderOverviewRuler: IObservable; @@ -124,7 +127,6 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { this._register(new UnchangedRangesFeature(this._originalEditor, this._modifiedEditor, this._diffModel)); this._register(new ViewZoneAlignment(this._originalEditor, this._modifiedEditor, this._diffModel)); - this._register(this._instantiationService.createInstance(OverviewRulerPart, this._originalEditor, this._modifiedEditor, @@ -141,6 +143,15 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { codeEditorService.addDiffEditor(this); this._register(keepAlive(this._layoutInfo, true)); + + this._register(new MovedBlocksLinesPart( + this.elements.root, + this._diffModel, + this._layoutInfo.map(i => i.originalEditor), + this._layoutInfo.map(i => i.modifiedEditor), + this._originalEditor, + this._modifiedEditor, + )); } private readonly _layoutInfo = derived('modifiedEditorLayoutInfo', (reader) => { @@ -161,7 +172,10 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { height }); - return { modifiedEditor: this._modifiedEditor.getLayoutInfo() }; + return { + modifiedEditor: this._modifiedEditor.getLayoutInfo(), + originalEditor: this._originalEditor.getLayoutInfo(), + }; }); private readonly _decorations = derived('decorations', (reader) => { @@ -170,23 +184,70 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { return null; } + const currentMove = this._diffModel.read(reader)!.syncedMovedTexts.read(reader); + const originalDecorations: IModelDeltaDecoration[] = []; const modifiedDecorations: IModelDeltaDecoration[] = []; - for (const c of diff.changes) { - const fullRangeOriginal = c.originalRange.toInclusiveRange(); - if (fullRangeOriginal) { - originalDecorations.push({ range: fullRangeOriginal, options: diffFullLineDeleteDecoration }); - } - const fullRangeModified = c.modifiedRange.toInclusiveRange(); - if (fullRangeModified) { - modifiedDecorations.push({ range: fullRangeModified, options: diffFullLineAddDecoration }); + for (const m of diff.mappings) { + const fullRangeOriginal = LineRange.subtract(m.lineRangeMapping.originalRange, currentMove?.lineRangeMapping.originalRange) + .map(i => i.toInclusiveRange()).filter(isDefined); + for (const range of fullRangeOriginal) { + originalDecorations.push({ range, options: diffFullLineDeleteDecoration }); } - for (const i of c.innerChanges || []) { + const fullRangeModified = LineRange.subtract(m.lineRangeMapping.modifiedRange, currentMove?.lineRangeMapping.modifiedRange) + .map(i => i.toInclusiveRange()).filter(isDefined); + for (const range of fullRangeModified) { + modifiedDecorations.push({ range, options: diffFullLineAddDecoration }); + } + + for (const i of m.lineRangeMapping.innerChanges || []) { + if (currentMove + && (currentMove.lineRangeMapping.originalRange.intersect(new LineRange(i.originalRange.startLineNumber, i.originalRange.endLineNumber)) + || currentMove.lineRangeMapping.modifiedRange.intersect(new LineRange(i.modifiedRange.startLineNumber, i.modifiedRange.endLineNumber)))) { + continue; + } originalDecorations.push({ range: i.originalRange, options: diffDeleteDecoration }); modifiedDecorations.push({ range: i.modifiedRange, options: diffAddDecoration }); } } + + if (currentMove) { + for (const m of currentMove.changes) { + const fullRangeOriginal = m.originalRange.toInclusiveRange(); + if (fullRangeOriginal) { + originalDecorations.push({ range: fullRangeOriginal, options: diffFullLineDeleteDecoration }); + } + const fullRangeModified = m.modifiedRange.toInclusiveRange(); + if (fullRangeModified) { + modifiedDecorations.push({ range: fullRangeModified, options: diffFullLineAddDecoration }); + } + + for (const i of m.innerChanges || []) { + originalDecorations.push({ range: i.originalRange, options: diffDeleteDecoration }); + modifiedDecorations.push({ range: i.modifiedRange, options: diffAddDecoration }); + } + } + } + + for (const m of diff.movedTexts) { + originalDecorations.push({ + range: m.lineRangeMapping.originalRange.toInclusiveRange()!, options: { + description: 'moved', + blockClassName: 'movedOriginal', + blockPadding: [MovedBlocksLinesPart.movedCodeBlockPadding, 0, MovedBlocksLinesPart.movedCodeBlockPadding, MovedBlocksLinesPart.movedCodeBlockPadding], + } + }); + + modifiedDecorations.push({ + range: m.lineRangeMapping.modifiedRange.toInclusiveRange()!, options: { + description: 'moved', + blockClassName: 'movedModified', + blockPadding: [4, 0, 4, 4], + } + }); + } + return { originalDecorations, modifiedDecorations }; }); @@ -202,19 +263,34 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { } private _createLeftHandSideEditor(options: Readonly, codeEditorWidgetOptions: ICodeEditorWidgetOptions): CodeEditorWidget { - const editor = this._createInnerEditor(this._instantiationService, this.elements.original, this._adjustOptionsForLeftHandSide(options), codeEditorWidgetOptions); + const editor = this._constructInnerEditor(this._instantiationService, this.elements.original, this._adjustOptionsForLeftHandSide(options), codeEditorWidgetOptions); const isInDiffLeftEditorKey = this._contextKeyService.createKey('isInDiffLeftEditor', editor.hasWidgetFocus()); this._register(editor.onDidFocusEditorWidget(() => isInDiffLeftEditorKey.set(true))); this._register(editor.onDidBlurEditorWidget(() => isInDiffLeftEditorKey.set(false))); + this._register(editor.onDidChangeCursorPosition(e => { + const m = this._diffModel.get(); + if (!m) { return; } + + const movedText = m.diff.get()!.movedTexts.find(m => m.lineRangeMapping.originalRange.contains(e.position.lineNumber)); + + m.syncedMovedTexts.set(movedText, undefined); + })); return editor; } private _createRightHandSideEditor(options: Readonly, codeEditorWidgetOptions: ICodeEditorWidgetOptions): CodeEditorWidget { - const editor = this._createInnerEditor(this._instantiationService, this.elements.modified, this._adjustOptionsForRightHandSide(options), codeEditorWidgetOptions); + const editor = this._constructInnerEditor(this._instantiationService, this.elements.modified, this._adjustOptionsForRightHandSide(options), codeEditorWidgetOptions); const isInDiffRightEditorKey = this._contextKeyService.createKey('isInDiffRightEditor', editor.hasWidgetFocus()); this._register(editor.onDidFocusEditorWidget(() => isInDiffRightEditorKey.set(true))); this._register(editor.onDidBlurEditorWidget(() => isInDiffRightEditorKey.set(false))); + this._register(editor.onDidChangeCursorPosition(e => { + const m = this._diffModel.get(); + if (!m) { return; } + const movedText = m.diff.get()!.movedTexts.find(m => m.lineRangeMapping.modifiedRange.contains(e.position.lineNumber)); + + m.syncedMovedTexts.set(movedText, undefined); + })); // Revert change when an arrow is clicked. /*TODO this._register(editor.onMouseDown(event => { @@ -238,8 +314,8 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { return editor; } - protected _createInnerEditor(instantiationService: IInstantiationService, container: HTMLElement, options: Readonly, editorWidgetOptions: ICodeEditorWidgetOptions): CodeEditorWidget { - const editor = instantiationService.createInstance(CodeEditorWidget, container, options, editorWidgetOptions); + protected _constructInnerEditor(instantiationService: IInstantiationService, container: HTMLElement, options: Readonly, editorWidgetOptions: ICodeEditorWidgetOptions): CodeEditorWidget { + const editor = this._createInnerEditor(instantiationService, container, options, editorWidgetOptions); this._register(editor.onDidContentSizeChange(e => { const width = this._originalEditor.getContentWidth() + this._modifiedEditor.getContentWidth() + OverviewRulerPart.ENTIRE_DIFF_OVERVIEW_WIDTH; @@ -252,32 +328,17 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { contentWidthChanged: e.contentWidthChanged }); })); + return editor; + } - this._register(editor.onDidScrollChange((e) => { - if (this._isHandlingScrollEvent) { - return; - } - if (!e.scrollTopChanged && !e.scrollLeftChanged && !e.scrollHeightChanged) { - return; - } - this._isHandlingScrollEvent = true; - try { - const otherEditor = editor === this._originalEditor ? this._modifiedEditor : this._originalEditor; - otherEditor.setScrollPosition({ - scrollLeft: e.scrollLeft, - scrollTop: e.scrollTop - }); - } finally { - this._isHandlingScrollEvent = false; - } - })); - + protected _createInnerEditor(instantiationService: IInstantiationService, container: HTMLElement, options: Readonly, editorWidgetOptions: ICodeEditorWidgetOptions): CodeEditorWidget { + const editor = instantiationService.createInstance(CodeEditorWidget, container, options, editorWidgetOptions); return editor; } private _adjustOptionsForLeftHandSide(options: Readonly): IEditorConstructionOptions { const result = this._adjustOptionsForSubEditor(options); - if (!options.renderSideBySide) { + if (!this._options.get().renderSideBySide) { // never wrap hidden editor result.wordWrapOverride1 = 'off'; result.wordWrapOverride2 = 'off'; @@ -289,7 +350,7 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { result.ariaLabel = options.originalAriaLabel; } result.ariaLabel = this._updateAriaLabel(result.ariaLabel); - result.readOnly = !options.originalEditable; + result.readOnly = !this._options.get().originalEditable; result.dropIntoEditor = { enabled: !result.readOnly }; result.extraEditorClassName = 'original-in-monaco-diff-editor'; return result; @@ -386,8 +447,9 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { this._options.map(o => o.ignoreTrimWhitespace), this._options.map(o => o.maxComputationTime), this._options.map(o => o.experimental.collapseUnchangedRegions!), + this._options.map(o => o.experimental.showMoves!), this._instantiationService.createInstance(WorkerBasedDocumentDiffProvider, this._options.get()) - ) : null, undefined); + ) : undefined, undefined); } override updateOptions(_newOptions: IDiffEditorOptions): void { @@ -439,24 +501,24 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { //throw new Error('Method not implemented.'); } - private _goTo(diff: LineRangeMapping): void { - this._modifiedEditor.setPosition(new Position(diff.modifiedRange.startLineNumber, 1)); - this._modifiedEditor.revealRangeInCenter(diff.modifiedRange.toExclusiveRange()); + private _goTo(diff: DiffMapping): void { + this._modifiedEditor.setPosition(new Position(diff.lineRangeMapping.modifiedRange.startLineNumber, 1)); + this._modifiedEditor.revealRangeInCenter(diff.lineRangeMapping.modifiedRange.toExclusiveRange()); } goToDiff(target: 'previous' | 'next'): void { - const diffs = this._diffModel.get()?.diff.get()?.changes; + const diffs = this._diffModel.get()?.diff.get()?.mappings; if (!diffs || diffs.length === 0) { return; } const curLineNumber = this._modifiedEditor.getPosition()!.lineNumber; - let diff: LineRangeMapping | undefined; + let diff: DiffMapping | undefined; if (target === 'next') { - diff = diffs.find(d => d.modifiedRange.startLineNumber > curLineNumber) ?? diffs[0]; + diff = diffs.find(d => d.lineRangeMapping.modifiedRange.startLineNumber > curLineNumber) ?? diffs[0]; } else { - diff = findLast(diffs, d => d.modifiedRange.startLineNumber < curLineNumber) ?? diffs[diffs.length - 1]; + diff = findLast(diffs, d => d.lineRangeMapping.modifiedRange.startLineNumber < curLineNumber) ?? diffs[diffs.length - 1]; } this._goTo(diff); } @@ -468,7 +530,7 @@ export class DiffEditorWidget2 extends DelegatingEditor implements IDiffEditor { } // wait for the diff computation to finish waitForState(diffModel.isDiffUpToDate, s => s).then(() => { - const diffs = diffModel.diff.get()?.changes; + const diffs = diffModel.diff.get()?.mappings; if (!diffs || diffs.length === 0) { return; } @@ -495,6 +557,7 @@ function validateDiffEditorOptions(options: Readonly, defaul accessibilityVerbose: validateBooleanOption(options.accessibilityVerbose, defaults.accessibilityVerbose), experimental: { collapseUnchangedRegions: validateBooleanOption(options.experimental?.collapseUnchangedRegions, defaults.experimental.collapseUnchangedRegions!), + showMoves: validateBooleanOption(options.experimental?.showMoves, defaults.experimental.showMoves!), }, }; } diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/diffModel.ts b/src/vs/editor/browser/widget/diffEditorWidget2/diffModel.ts index 2c106291d44..c8de0cdcf36 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/diffModel.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget2/diffModel.ts @@ -10,7 +10,7 @@ import { autorunWithStore2 } from 'vs/base/common/observableImpl/autorun'; import { LineRange } from 'vs/editor/common/core/lineRange'; import { Range } from 'vs/editor/common/core/range'; import { IDocumentDiff, IDocumentDiffProvider } from 'vs/editor/common/diff/documentDiffProvider'; -import { LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; +import { LineRangeMapping, MovedText, RangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; import { lineRangeMappingFromRangeMappings } from 'vs/editor/common/diff/standardLinesDiffComputer'; import { IDiffEditorModel } from 'vs/editor/common/editorCommon'; import { ITextModel } from 'vs/editor/common/model'; @@ -22,35 +22,39 @@ export class DiffModel extends Disposable { private readonly _isDiffUpToDate = observableValue('isDiffUpToDate', false); public readonly isDiffUpToDate: IObservable = this._isDiffUpToDate; - private readonly _diff = observableValue('diff', undefined); - public readonly diff: IObservable = this._diff; + private readonly _diff = observableValue('diff', undefined); + public readonly diff: IObservable = this._diff; private readonly _unchangedRegions = observableValue<{ regions: UnchangedRegion[]; originalDecorationIds: string[]; modifiedDecorationIds: string[] }>('unchangedRegion', { regions: [], originalDecorationIds: [], modifiedDecorationIds: [] }); public readonly unchangedRegions: IObservable = derived('unchangedRegions', r => - this.hideUnchangedRegions.read(r) ? this._unchangedRegions.read(r).regions : [] + this._hideUnchangedRegions.read(r) ? this._unchangedRegions.read(r).regions : [] ); + public readonly syncedMovedTexts = observableValue('syncedMovedText', undefined); + constructor( model: IDiffEditorModel, ignoreTrimWhitespace: IObservable, maxComputationTimeMs: IObservable, - private readonly hideUnchangedRegions: IObservable, + private readonly _hideUnchangedRegions: IObservable, + private readonly _showMoves: IObservable, documentDiffProvider: IDocumentDiffProvider, ) { super(); const contentChangedSignal = observableSignal('contentChangedSignal'); const debouncer = this._register(new RunOnceScheduler(() => contentChangedSignal.trigger(undefined), 200)); + this._register(model.modified.onDidChangeContent((e) => { const diff = this._diff.get(); if (!diff) { return; } - const textEdits = TextEditInfo.fromModelContentChanges(e.changes); + /*const textEdits = TextEditInfo.fromModelContentChanges(e.changes); this._diff.set( applyModifiedEdits(diff, textEdits, model.original, model.modified), undefined - ); + );*/ debouncer.schedule(); })); this._register(model.original.onDidChangeContent((e) => { @@ -58,11 +62,11 @@ export class DiffModel extends Disposable { if (!diff) { return; } - const textEdits = TextEditInfo.fromModelContentChanges(e.changes); + /*const textEdits = TextEditInfo.fromModelContentChanges(e.changes); this._diff.set( applyOriginalEdits(diff, textEdits, model.original, model.modified), undefined - ); + );*/ debouncer.schedule(); })); @@ -71,10 +75,7 @@ export class DiffModel extends Disposable { this._register(autorunWithStore2('compute diff', async (reader, store) => { debouncer.cancel(); contentChangedSignal.read(reader); - documentDiffProviderOptionChanged.read(reader); - const ignoreTrimWhitespaceVal = ignoreTrimWhitespace.read(reader); - const maxComputationTimeMsVal = maxComputationTimeMs.read(reader); this._isDiffUpToDate.set(false, undefined); @@ -91,8 +92,9 @@ export class DiffModel extends Disposable { })); let result = await documentDiffProvider.computeDiff(model.original, model.modified, { - ignoreTrimWhitespace: ignoreTrimWhitespaceVal, - maxComputationTimeMs: maxComputationTimeMsVal, + ignoreTrimWhitespace: ignoreTrimWhitespace.read(reader), + maxComputationTimeMs: maxComputationTimeMs.read(reader), + computeMoves: this._showMoves.read(reader), }); result = applyOriginalEdits(result, originalTextEditInfos, model.original, model.modified); @@ -135,7 +137,7 @@ export class DiffModel extends Disposable { ); transaction(tx => { - this._diff.set(result, tx); + this._diff.set(DiffState.fromDiffResult(result), tx); this._isDiffUpToDate.set(true, tx); this._unchangedRegions.set( @@ -171,8 +173,47 @@ export class DiffModel extends Disposable { } } +export class DiffState { + public static fromDiffResult(result: IDocumentDiff): DiffState { + return new DiffState( + result.changes.map(c => new DiffMapping(c)), + result.moves || [] + ); + } + + constructor( + public readonly mappings: readonly DiffMapping[], + public readonly movedTexts: readonly MovedText[], + ) { } +} + +export class DiffMapping { + constructor( + readonly lineRangeMapping: LineRangeMapping, + ) { + /* + readonly movedTo: MovedText | undefined, + readonly movedFrom: MovedText | undefined, + + if (movedTo) { + assertFn(() => + movedTo.lineRangeMapping.modifiedRange.equals(lineRangeMapping.modifiedRange) + && lineRangeMapping.originalRange.isEmpty + && !movedFrom + ); + } else if (movedFrom) { + assertFn(() => + movedFrom.lineRangeMapping.originalRange.equals(lineRangeMapping.originalRange) + && lineRangeMapping.modifiedRange.isEmpty + && !movedTo + ); + } + */ + } +} + export class UnchangedRegion { - public static fromDiffs(changes: LineRangeMapping[], originalLineCount: number, modifiedLineCount: number): UnchangedRegion[] { + public static fromDiffs(changes: readonly LineRangeMapping[], originalLineCount: number, modifiedLineCount: number): UnchangedRegion[] { const inversedMappings = LineRangeMapping.inverse(changes, originalLineCount, modifiedLineCount); const result: UnchangedRegion[] = []; @@ -301,6 +342,7 @@ function applyOriginalEdits(diff: IDocumentDiff, textEdits: TextEditInfo[], orig identical: false, quitEarly: false, changes, + moves: [], }; } @@ -340,5 +382,6 @@ function applyModifiedEdits(diff: IDocumentDiff, textEdits: TextEditInfo[], orig identical: false, quitEarly: false, changes, + moves: [], }; } diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/lineAlignment.ts b/src/vs/editor/browser/widget/diffEditorWidget2/lineAlignment.ts index 46d34f49920..7cd8ef95a8b 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/lineAlignment.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget2/lineAlignment.ts @@ -5,22 +5,33 @@ import { ArrayQueue } from 'vs/base/common/arrays'; import { Disposable } from 'vs/base/common/lifecycle'; -import { IObservable, observableSignalFromEvent, derived } from 'vs/base/common/observable'; -import { autorunWithStore2 } from 'vs/base/common/observableImpl/autorun'; +import { IObservable, observableSignalFromEvent, derived, observableValue, observableFromEvent } from 'vs/base/common/observable'; +import { autorun, autorunWithStore2 } from 'vs/base/common/observableImpl/autorun'; import { IViewZone } from 'vs/editor/browser/editorBrowser'; import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditorWidget'; -import { DiffModel } from 'vs/editor/browser/widget/diffEditorWidget2/diffModel'; -import { joinCombine } from 'vs/editor/browser/widget/diffEditorWidget2/utils'; +import { DiffMapping, DiffModel } from 'vs/editor/browser/widget/diffEditorWidget2/diffModel'; +import { animatedObservable, joinCombine } from 'vs/editor/browser/widget/diffEditorWidget2/utils'; import { EditorOption } from 'vs/editor/common/config/editorOptions'; import { LineRange } from 'vs/editor/common/core/lineRange'; import { Position } from 'vs/editor/common/core/position'; -import { LineRangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; +import { ScrollType } from 'vs/editor/common/editorCommon'; export class ViewZoneAlignment extends Disposable { + private readonly _origExtraHeight = observableValue('origExtraHeight', 0); + private readonly _modExtraHeight = observableValue('modExtraHeight', 0); + + private readonly _originalScrollTop: IObservable; + private readonly _originalScrollOffset = observableValue('originalScrollOffset', 0); + private readonly _originalScrollOffsetAnimated = animatedObservable(this._originalScrollOffset, this._store); + + private readonly _modifiedScrollTop: IObservable; + private readonly _modifiedScrollOffset = observableValue('modifiedScrollOffset', 0); + private readonly _modifiedScrollOffsetAnimated = animatedObservable(this._modifiedScrollOffset, this._store); + constructor( private readonly _originalEditor: CodeEditorWidget, private readonly _modifiedEditor: CodeEditorWidget, - private readonly _diffModel: IObservable, + private readonly _diffModel: IObservable, ) { super(); @@ -39,13 +50,28 @@ export class ViewZoneAlignment extends Disposable { const alignmentViewZoneIdsMod = new Set(); const alignments = derived('alignments', (reader) => { - const diff = this._diffModel.read(reader)?.diff.read(reader); - if (!diff) { return null; } + const diffModel = this._diffModel.read(reader); + const diff = diffModel?.diff.read(reader); + if (!diffModel || !diff) { return null; } origViewZonesChanged.read(reader); modViewZonesChanged.read(reader); - return computeRangeAlignment(this._originalEditor, this._modifiedEditor, diff.changes, alignmentViewZoneIdsOrig, alignmentViewZoneIdsMod); + return computeRangeAlignment(this._originalEditor, this._modifiedEditor, diff.mappings, alignmentViewZoneIdsOrig, alignmentViewZoneIdsMod); + }); + + const alignmentsSyncedMovedText = derived('alignments', (reader) => { + origViewZonesChanged.read(reader); + modViewZonesChanged.read(reader); + + const syncedMovedText = this._diffModel.read(reader)?.syncedMovedTexts.read(reader); + if (!syncedMovedText) { + return null; + } + const mappings = syncedMovedText.changes.map(c => new DiffMapping(c)); + + // TOD dont include alignments outside syncedMovedText + return computeRangeAlignment(this._originalEditor, this._modifiedEditor, mappings, alignmentViewZoneIdsOrig, alignmentViewZoneIdsMod); }); function createFakeLinesDiv(): HTMLElement { @@ -60,16 +86,43 @@ export class ViewZoneAlignment extends Disposable { const origViewZones: IViewZone[] = []; const modViewZones: IViewZone[] = []; + const _modExtraHeight = this._modExtraHeight.read(reader); + if (_modExtraHeight > 0) { + modViewZones.push({ + afterLineNumber: 0, + domNode: document.createElement('div'), + heightInPx: _modExtraHeight, + }); + } + const _origExtraHeight = this._origExtraHeight.read(reader); + if (_origExtraHeight > 0) { + origViewZones.push({ + afterLineNumber: 0, + domNode: document.createElement('div'), + heightInPx: _origExtraHeight, + }); + } + + const syncedMovedText = this._diffModel.read(reader)?.syncedMovedTexts.read(reader); + if (alignments_) { for (const a of alignments_) { const delta = a.modifiedHeightInPx - a.originalHeightInPx; if (delta > 0) { + if (syncedMovedText?.lineRangeMapping.originalRange.contains(a.originalRange.endLineNumberExclusive - 1)) { + continue; + } + origViewZones.push({ afterLineNumber: a.originalRange.endLineNumberExclusive - 1, domNode: createFakeLinesDiv(), heightInPx: delta, }); } else { + if (syncedMovedText?.lineRangeMapping.modifiedRange.contains(a.modifiedRange.endLineNumberExclusive - 1)) { + continue; + } + modViewZones.push({ afterLineNumber: a.modifiedRange.endLineNumberExclusive - 1, domNode: createFakeLinesDiv(), @@ -79,6 +132,28 @@ export class ViewZoneAlignment extends Disposable { } } + for (const a of alignmentsSyncedMovedText.read(reader) ?? []) { + if (!syncedMovedText?.lineRangeMapping.originalRange.intersect(a.originalRange) + && !syncedMovedText?.lineRangeMapping.modifiedRange.intersect(a.modifiedRange)) { + // ignore unrelated alignments outside the synced moved text + continue; + } + const delta = a.modifiedHeightInPx - a.originalHeightInPx; + if (delta > 0) { + origViewZones.push({ + afterLineNumber: a.originalRange.endLineNumberExclusive - 1, + domNode: createFakeLinesDiv(), + heightInPx: delta, + }); + } else { + modViewZones.push({ + afterLineNumber: a.modifiedRange.endLineNumberExclusive - 1, + domNode: createFakeLinesDiv(), + heightInPx: -delta, + }); + } + } + return { orig: origViewZones, mod: modViewZones }; }); @@ -98,47 +173,66 @@ export class ViewZoneAlignment extends Disposable { isChangingViewZones = false; })); - } -} + this._originalScrollTop = observableFromEvent(this._originalEditor.onDidScrollChange, () => this._originalEditor.getScrollTop()); + this._modifiedScrollTop = observableFromEvent(this._modifiedEditor.onDidScrollChange, () => this._modifiedEditor.getScrollTop()); -interface AdditionalLineHeightInfo { - lineNumber: number; - heightInPx: number; -} + // origExtraHeight + origOffset - origScrollTop = modExtraHeight + modOffset - modScrollTop -function getAdditionalLineHeights(editor: CodeEditorWidget, viewZonesToIgnore: ReadonlySet): readonly AdditionalLineHeightInfo[] { - const viewZoneHeights: { lineNumber: number; heightInPx: number }[] = []; - const wrappingZoneHeights: { lineNumber: number; heightInPx: number }[] = []; + // origScrollTop = origExtraHeight + origOffset - modExtraHeight - modOffset + modScrollTop + // modScrollTop = modExtraHeight + modOffset - origExtraHeight - origOffset + origScrollTop - const hasWrapping = editor.getOption(EditorOption.wrappingInfo).wrappingColumn !== -1; - const coordinatesConverter = editor._getViewModel()!.coordinatesConverter; - if (hasWrapping) { - for (let i = 1; i <= editor.getModel()!.getLineCount(); i++) { - const lineCount = coordinatesConverter.getModelLineViewLineCount(i); - if (lineCount > 1) { - wrappingZoneHeights.push({ lineNumber: i, heightInPx: lineCount - 1 }); + // origOffset - modOffset = heightOfLines(1..Y) - heightOfLines(1..X) + // origScrollTop >= 0, modScrollTop >= 0 + + this._register(autorun('update scroll modified', (reader) => { + const newScrollTopModified = this._originalScrollTop.read(reader) + - (this._originalScrollOffsetAnimated.get() - this._modifiedScrollOffsetAnimated.read(reader)) + - (this._origExtraHeight.get() - this._modExtraHeight.read(reader)); + if (newScrollTopModified !== this._modifiedEditor.getScrollTop()) { + this._modifiedEditor.setScrollTop(newScrollTopModified, ScrollType.Immediate); } - } + })); + + this._register(autorun('update scroll original', (reader) => { + const newScrollTopOriginal = this._modifiedScrollTop.read(reader) + - (this._modifiedScrollOffsetAnimated.get() - this._originalScrollOffsetAnimated.read(reader)) + - (this._modExtraHeight.get() - this._origExtraHeight.read(reader)); + if (newScrollTopOriginal !== this._originalEditor.getScrollTop()) { + this._originalEditor.setScrollTop(newScrollTopOriginal, ScrollType.Immediate); + } + })); + + + this._register(autorun('update', reader => { + const m = this._diffModel.read(reader)?.syncedMovedTexts.read(reader); + + let deltaOrigToMod = 0; + if (m) { + const trueTopOriginal = this._originalEditor.getTopForLineNumber(m.lineRangeMapping.originalRange.startLineNumber, true) - this._origExtraHeight.get(); + const trueTopModified = this._modifiedEditor.getTopForLineNumber(m.lineRangeMapping.modifiedRange.startLineNumber, true) - this._modExtraHeight.get(); + deltaOrigToMod = trueTopModified - trueTopOriginal; + } + + if (deltaOrigToMod > 0) { + this._modExtraHeight.set(0, undefined); + this._origExtraHeight.set(deltaOrigToMod, undefined); + } else if (deltaOrigToMod < 0) { + this._modExtraHeight.set(-deltaOrigToMod, undefined); + this._origExtraHeight.set(0, undefined); + } else { + setTimeout(() => { + this._modExtraHeight.set(0, undefined); + this._origExtraHeight.set(0, undefined); + }, 400); + } + + if (this._modifiedEditor.hasTextFocus()) { + this._originalScrollOffset.set(this._modifiedScrollOffset.get() - deltaOrigToMod, undefined, true); + } else { + this._modifiedScrollOffset.set(this._originalScrollOffset.get() + deltaOrigToMod, undefined, true); + } + })); } - - for (const w of editor.getWhitespaces()) { - if (viewZonesToIgnore.has(w.id)) { - continue; - } - const modelLineNumber = coordinatesConverter.convertViewPositionToModelPosition( - new Position(w.afterLineNumber, 1) - ).lineNumber; - viewZoneHeights.push({ lineNumber: modelLineNumber, heightInPx: w.height }); - } - - const result = joinCombine( - viewZoneHeights, - wrappingZoneHeights, - v => v.lineNumber, - (v1, v2) => ({ lineNumber: v1.lineNumber, heightInPx: v1.heightInPx + v2.heightInPx }) - ); - - return result; } interface IRangeAlignment { @@ -153,7 +247,7 @@ interface IRangeAlignment { function computeRangeAlignment( originalEditor: CodeEditorWidget, modifiedEditor: CodeEditorWidget, - diffs: LineRangeMapping[], + diffs: readonly DiffMapping[], originalEditorAlignmentViewZones: ReadonlySet, modifiedEditorAlignmentViewZones: ReadonlySet, ): IRangeAlignment[] { @@ -211,7 +305,8 @@ function computeRangeAlignment( } } - for (const c of diffs) { + for (const m of diffs) { + const c = m.lineRangeMapping; handleAlignmentsOutsideOfDiffs(c.originalRange.startLineNumber, c.modifiedRange.startLineNumber); const originalAdditionalHeight = originalLineHeightOverrides @@ -235,3 +330,44 @@ function computeRangeAlignment( return result; } + +interface AdditionalLineHeightInfo { + lineNumber: number; + heightInPx: number; +} + +function getAdditionalLineHeights(editor: CodeEditorWidget, viewZonesToIgnore: ReadonlySet): readonly AdditionalLineHeightInfo[] { + const viewZoneHeights: { lineNumber: number; heightInPx: number }[] = []; + const wrappingZoneHeights: { lineNumber: number; heightInPx: number }[] = []; + + const hasWrapping = editor.getOption(EditorOption.wrappingInfo).wrappingColumn !== -1; + const coordinatesConverter = editor._getViewModel()!.coordinatesConverter; + const editorLineHeight = editor.getOption(EditorOption.lineHeight); + if (hasWrapping) { + for (let i = 1; i <= editor.getModel()!.getLineCount(); i++) { + const lineCount = coordinatesConverter.getModelLineViewLineCount(i); + if (lineCount > 1) { + wrappingZoneHeights.push({ lineNumber: i, heightInPx: editorLineHeight * (lineCount - 1) }); + } + } + } + + for (const w of editor.getWhitespaces()) { + if (viewZonesToIgnore.has(w.id)) { + continue; + } + const modelLineNumber = coordinatesConverter.convertViewPositionToModelPosition( + new Position(w.afterLineNumber, 1) + ).lineNumber; + viewZoneHeights.push({ lineNumber: modelLineNumber, heightInPx: w.height }); + } + + const result = joinCombine( + viewZoneHeights, + wrappingZoneHeights, + v => v.lineNumber, + (v1, v2) => ({ lineNumber: v1.lineNumber, heightInPx: v1.heightInPx + v2.heightInPx }) + ); + + return result; +} diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/movedBlocksLines.ts b/src/vs/editor/browser/widget/diffEditorWidget2/movedBlocksLines.ts new file mode 100644 index 00000000000..5502568cc69 --- /dev/null +++ b/src/vs/editor/browser/widget/diffEditorWidget2/movedBlocksLines.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { IObservable, autorun, observableFromEvent } from 'vs/base/common/observable'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; +import { DiffModel } from 'vs/editor/browser/widget/diffEditorWidget2/diffModel'; +import { EditorLayoutInfo } from 'vs/editor/common/config/editorOptions'; +import { LineRange } from 'vs/editor/common/core/lineRange'; + +export class MovedBlocksLinesPart extends Disposable { + public static readonly movedCodeBlockPadding = 4; + + constructor( + private readonly _rootElement: HTMLElement, + private readonly _diffModel: IObservable, + private readonly _originalEditorLayoutInfo: IObservable, + private readonly _modifiedEditorLayoutInfo: IObservable, + private readonly _originalEditor: ICodeEditor, + private readonly _modifiedEditor: ICodeEditor, + ) { + super(); + + const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + element.setAttribute('class', 'moved-blocks-lines'); + this._rootElement.appendChild(element); + + this._register(autorun('update', (reader) => { + const info = this._originalEditorLayoutInfo.read(reader); + const info2 = this._modifiedEditorLayoutInfo.read(reader); + if (!info || !info2) { + return; + } + + element.style.left = `${info.width - info.verticalScrollbarWidth}px`; + element.style.height = `${info.height}px`; + element.style.width = `${info.verticalScrollbarWidth + info.contentLeft - MovedBlocksLinesPart.movedCodeBlockPadding}px`; + })); + + const originalScrollTop = observableFromEvent(this._originalEditor.onDidScrollChange, () => this._originalEditor.getScrollTop()); + const modifiedScrollTop = observableFromEvent(this._modifiedEditor.onDidScrollChange, () => this._modifiedEditor.getScrollTop()); + + + this._register(autorun('update', (reader) => { + const info = this._originalEditorLayoutInfo.read(reader); + const info2 = this._modifiedEditorLayoutInfo.read(reader); + if (!info || !info2) { + return; + } + const width = info.verticalScrollbarWidth + info.contentLeft - MovedBlocksLinesPart.movedCodeBlockPadding; + + const moves = this._diffModel.read(reader)?.diff.read(reader)?.movedTexts; + if (!moves) { + return; + } + + element.replaceChildren(); + + let idx = 0; + for (const m of moves) { + function computeLineStart(range: LineRange, editor: ICodeEditor) { + const t1 = editor.getTopForLineNumber(range.startLineNumber); + const t2 = editor.getTopForLineNumber(range.endLineNumberExclusive); + return (t1 + t2) / 2; + } + + const start = computeLineStart(m.lineRangeMapping.originalRange, this._originalEditor); + const startOffset = originalScrollTop.read(reader); + const end = computeLineStart(m.lineRangeMapping.modifiedRange, this._modifiedEditor); + const endOffset = modifiedScrollTop.read(reader); + + const top = start - startOffset; + const bottom = end - endOffset; + + const center = (width / 2) - moves.length * 5 + idx * 10; + idx++; + + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', `M ${0} ${top} L ${center} ${top} L ${center} ${bottom} L ${width} ${bottom}`); + + path.setAttribute('fill', 'none'); + element.appendChild(path); + } + })); + } +} diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/overviewRulerPart.ts b/src/vs/editor/browser/widget/diffEditorWidget2/overviewRulerPart.ts index 01b982c9e96..8b7cb92458c 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/overviewRulerPart.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget2/overviewRulerPart.ts @@ -27,7 +27,7 @@ export class OverviewRulerPart extends Disposable { private readonly _originalEditor: CodeEditorWidget, private readonly _modifiedEditor: CodeEditorWidget, private readonly _rootElement: HTMLElement, - private readonly _diffModel: IObservable, + private readonly _diffModel: IObservable, private readonly _rootWidth: IObservable, private readonly _rootHeight: IObservable, private readonly _modifiedEditorLayoutInfo: IObservable, @@ -92,7 +92,7 @@ export class OverviewRulerPart extends Disposable { store.add(autorun('set overview ruler zones', (reader) => { const colors = currentColors.read(reader); - const diff = m?.diff.read(reader)?.changes; + const diff = m?.diff.read(reader)?.mappings; function createZones(ranges: LineRange[], color: Color) { return ranges @@ -100,8 +100,8 @@ export class OverviewRulerPart extends Disposable { .map(r => new OverviewRulerZone(r.startLineNumber, r.endLineNumberExclusive, r.length, color.toString())); } - originalOverviewRuler?.setZones(createZones((diff || []).map(d => d.originalRange), colors.removeColor)); - modifiedOverviewRuler?.setZones(createZones((diff || []).map(d => d.modifiedRange), colors.insertColor)); + originalOverviewRuler?.setZones(createZones((diff || []).map(d => d.lineRangeMapping.originalRange), colors.removeColor)); + modifiedOverviewRuler?.setZones(createZones((diff || []).map(d => d.lineRangeMapping.modifiedRange), colors.insertColor)); })); store.add(autorun('layout overview ruler', (reader) => { diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/style.css b/src/vs/editor/browser/widget/diffEditorWidget2/style.css index ce533d54d1b..8dc4a8022ac 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/style.css +++ b/src/vs/editor/browser/widget/diffEditorWidget2/style.css @@ -59,3 +59,22 @@ .merge-editor-conflict-actions > a:hover .codicon::before { cursor: pointer; } + +.movedOriginal { + border: 2px solid var(--vscode-diffEditor-move-border); +} + +.movedModified { + border: 2px solid var(--vscode-diffEditor-move-border); +} + +.moved-blocks-lines { + position: absolute; + pointer-events: none; +} + +.moved-blocks-lines path { + fill: none; + stroke: var(--vscode-diffEditor-move-border); + stroke-width: 2; +} diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/unchangedRanges.ts b/src/vs/editor/browser/widget/diffEditorWidget2/unchangedRanges.ts index eeb082ff5b9..a979ecf78ca 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/unchangedRanges.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget2/unchangedRanges.ts @@ -17,7 +17,7 @@ export class UnchangedRangesFeature extends Disposable { constructor( private readonly _originalEditor: CodeEditorWidget, private readonly _modifiedEditor: CodeEditorWidget, - private readonly _diffModel: IObservable, + private readonly _diffModel: IObservable, ) { super(); diff --git a/src/vs/editor/browser/widget/diffEditorWidget2/utils.ts b/src/vs/editor/browser/widget/diffEditorWidget2/utils.ts index 134ca395a40..2c2091f2de3 100644 --- a/src/vs/editor/browser/widget/diffEditorWidget2/utils.ts +++ b/src/vs/editor/browser/widget/diffEditorWidget2/utils.ts @@ -5,7 +5,7 @@ import { IDimension } from 'vs/base/browser/dom'; import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { IObservable, ISettableObservable, autorun, observableFromEvent, observableValue, transaction } from 'vs/base/common/observable'; +import { IObservable, ISettableObservable, autorun, autorunHandleChanges, observableFromEvent, observableValue, transaction } from 'vs/base/common/observable'; import { ElementSizeObserver } from 'vs/editor/browser/config/elementSizeObserver'; import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { IModelDeltaDecoration } from 'vs/editor/common/model'; @@ -119,3 +119,54 @@ export class ObservableElementSizeObserver extends Disposable { } } } + +export function animatedObservable(base: IObservable, store: DisposableStore): IObservable { + let targetVal = base.get(); + let startVal = targetVal; + let curVal = targetVal; + const result = observableValue('animatedValue', targetVal); + + let animationStartMs: number = -1; + const durationMs = 300; + let animationFrame: number | undefined = undefined; + + store.add(autorunHandleChanges('update value', { + createEmptyChangeSummary: () => ({ animate: false }), + handleChange: (ctx, s) => { + if (ctx.didChange(base)) { + s.animate = s.animate || ctx.change; + } + return true; + } + }, (reader, s) => { + if (animationFrame !== undefined) { + cancelAnimationFrame(animationFrame); + animationFrame = undefined; + } + + startVal = curVal; + targetVal = base.read(reader); + animationStartMs = Date.now() - (s.animate ? 0 : durationMs); + + update(); + })); + + function update() { + const passedMs = Date.now() - animationStartMs; + curVal = Math.floor(easeOutExpo(passedMs, startVal, targetVal - startVal, durationMs)); + + if (passedMs < durationMs) { + animationFrame = requestAnimationFrame(update); + } else { + curVal = targetVal; + } + + result.set(curVal, undefined); + } + + return result; +} + +function easeOutExpo(t: number, b: number, c: number, d: number): number { + return t === d ? b + c : c * (-Math.pow(2, -10 * t / d) + 1) + b; +} diff --git a/src/vs/editor/browser/widget/workerBasedDocumentDiffProvider.ts b/src/vs/editor/browser/widget/workerBasedDocumentDiffProvider.ts index 503698a8d4b..a2fc75d5296 100644 --- a/src/vs/editor/browser/widget/workerBasedDocumentDiffProvider.ts +++ b/src/vs/editor/browser/widget/workerBasedDocumentDiffProvider.ts @@ -54,6 +54,7 @@ export class WorkerBasedDocumentDiffProvider implements IDocumentDiffProvider, I ], identical: false, quitEarly: false, + moves: [], }; } diff --git a/src/vs/editor/common/config/editorConfigurationSchema.ts b/src/vs/editor/common/config/editorConfigurationSchema.ts index 3c5016a3952..d28fddba551 100644 --- a/src/vs/editor/common/config/editorConfigurationSchema.ts +++ b/src/vs/editor/common/config/editorConfigurationSchema.ts @@ -207,6 +207,11 @@ const editorConfiguration: IConfigurationNode = { default: false, description: nls.localize('collapseUnchangedRegions', "Controls whether the diff editor shows unchanged regions. Only works when 'diffEditor.experimental.useVersion2' is set."), }, + 'diffEditor.experimental.showMoves': { + type: 'boolean', + default: false, + description: nls.localize('showMoves', "Controls whether the diff editor should show detected code moves. Only works when 'diffEditor.experimental.useVersion2' is set."), + }, 'diffEditor.experimental.useVersion2': { type: 'boolean', default: false, diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index c421169589e..8b4d09bfd02 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -804,6 +804,10 @@ export interface IDiffEditorBaseOptions { * Defaults to false. */ collapseUnchangedRegions?: boolean; + /** + * Defaults to false. + */ + showMoves?: boolean; }; } diff --git a/src/vs/editor/common/core/lineRange.ts b/src/vs/editor/common/core/lineRange.ts index 01a3fa58feb..4789968d244 100644 --- a/src/vs/editor/common/core/lineRange.ts +++ b/src/vs/editor/common/core/lineRange.ts @@ -14,6 +14,24 @@ export class LineRange { return new LineRange(range.startLineNumber, range.endLineNumber); } + public static subtract(a: LineRange, b: LineRange | undefined): LineRange[] { + if (!b) { + return [a]; + } + if (a.startLineNumber < b.startLineNumber && b.endLineNumberExclusive < a.endLineNumberExclusive) { + return [ + new LineRange(a.startLineNumber, b.startLineNumber), + new LineRange(b.endLineNumberExclusive, a.endLineNumberExclusive) + ]; + } else if (b.startLineNumber <= a.startLineNumber && a.endLineNumberExclusive <= b.endLineNumberExclusive) { + return []; + } else if (b.endLineNumberExclusive < a.endLineNumberExclusive) { + return [new LineRange(Math.max(b.endLineNumberExclusive, a.startLineNumber), a.endLineNumberExclusive)]; + } else { + return [new LineRange(a.startLineNumber, Math.min(b.startLineNumber, a.endLineNumberExclusive))]; + } + } + /** * @param lineRanges An array of sorted line ranges. */ diff --git a/src/vs/editor/common/diff/documentDiffProvider.ts b/src/vs/editor/common/diff/documentDiffProvider.ts index d8cbb3fedf3..ad707d114b5 100644 --- a/src/vs/editor/common/diff/documentDiffProvider.ts +++ b/src/vs/editor/common/diff/documentDiffProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from 'vs/base/common/event'; -import { LineRangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; +import { LineRangeMapping, MovedText } from 'vs/editor/common/diff/linesDiffComputer'; import { ITextModel } from 'vs/editor/common/model'; /** @@ -36,6 +36,11 @@ export interface IDocumentDiffProviderOptions { * A diff computation should throw if it takes longer than this value. */ maxComputationTimeMs: number; + + /** + * If set, the diff computation should compute moves in addition to insertions and deletions. + */ + computeMoves: boolean; } /** @@ -55,5 +60,11 @@ export interface IDocumentDiff { /** * Maps all modified line ranges in the original to the corresponding line ranges in the modified text model. */ - readonly changes: LineRangeMapping[]; + readonly changes: readonly LineRangeMapping[]; + + /** + * Sorted by original line ranges. + * The original line ranges and the modified line ranges must be disjoint (but can be touching). + */ + readonly moves: readonly MovedText[]; } diff --git a/src/vs/editor/common/diff/linesDiffComputer.ts b/src/vs/editor/common/diff/linesDiffComputer.ts index 11a463ef086..e8fddf830a9 100644 --- a/src/vs/editor/common/diff/linesDiffComputer.ts +++ b/src/vs/editor/common/diff/linesDiffComputer.ts @@ -13,12 +13,19 @@ export interface ILinesDiffComputer { export interface ILinesDiffComputerOptions { readonly ignoreTrimWhitespace: boolean; readonly maxComputationTimeMs: number; + readonly computeMoves: boolean; } export class LinesDiff { constructor( readonly changes: readonly LineRangeMapping[], + /** + * Sorted by original line ranges. + * The original line ranges and the modified line ranges must be disjoint (but can be touching). + */ + readonly moves: readonly MovedText[], + /** * Indicates if the time out was reached. * In that case, the diffs might be an approximation and the user should be asked to rerun the diff with more time. @@ -32,7 +39,7 @@ export class LinesDiff { * Maps a line range in the original text model to a line range in the modified text model. */ export class LineRangeMapping { - public static inverse(mapping: LineRangeMapping[], originalLineCount: number, modifiedLineCount: number): LineRangeMapping[] { + public static inverse(mapping: readonly LineRangeMapping[], originalLineCount: number, modifiedLineCount: number): LineRangeMapping[] { const result: LineRangeMapping[] = []; let lastOriginalEndLineNumber = 1; let lastModifiedEndLineNumber = 1; @@ -124,3 +131,34 @@ export class RangeMapping { return `{${this.originalRange.toString()}->${this.modifiedRange.toString()}}`; } } + +export class SimpleLineRangeMapping { + constructor( + public readonly originalRange: LineRange, + public readonly modifiedRange: LineRange, + ) { + } + + public toString(): string { + return `{${this.originalRange.toString()}->${this.modifiedRange.toString()}}`; + } +} + +export class MovedText { + public readonly lineRangeMapping: SimpleLineRangeMapping; + + /** + * The diff from the original text to the moved text. + * Must be contained in the original/modified line range. + * Can be empty if the text didn't change (only moved). + */ + public readonly changes: readonly LineRangeMapping[]; + + constructor( + lineRangeMapping: SimpleLineRangeMapping, + changes: readonly LineRangeMapping[], + ) { + this.lineRangeMapping = lineRangeMapping; + this.changes = changes; + } +} diff --git a/src/vs/editor/common/diff/smartLinesDiffComputer.ts b/src/vs/editor/common/diff/smartLinesDiffComputer.ts index 7bac667790f..1dbcd56a95a 100644 --- a/src/vs/editor/common/diff/smartLinesDiffComputer.ts +++ b/src/vs/editor/common/diff/smartLinesDiffComputer.ts @@ -75,7 +75,7 @@ export class SmartLinesDiffComputer implements ILinesDiffComputer { ); }); - return new LinesDiff(changes, result.quitEarly); + return new LinesDiff(changes, [], result.quitEarly); } } @@ -91,7 +91,7 @@ export interface IDiffComputationResult { /** * The changes as (modern) line range mapping array. */ - changes2: LineRangeMapping[]; + changes2: readonly LineRangeMapping[]; } /** diff --git a/src/vs/editor/common/diff/standardLinesDiffComputer.ts b/src/vs/editor/common/diff/standardLinesDiffComputer.ts index c502bf4f0eb..cc994f4c19b 100644 --- a/src/vs/editor/common/diff/standardLinesDiffComputer.ts +++ b/src/vs/editor/common/diff/standardLinesDiffComputer.ts @@ -13,7 +13,7 @@ import { DateTimeout, ISequence, ITimeout, InfiniteTimeout, SequenceDiff } from import { DynamicProgrammingDiffing } from 'vs/editor/common/diff/algorithms/dynamicProgrammingDiffing'; import { optimizeSequenceDiffs, smoothenSequenceDiffs } from 'vs/editor/common/diff/algorithms/joinSequenceDiffs'; import { MyersDiffAlgorithm } from 'vs/editor/common/diff/algorithms/myersDiffAlgorithm'; -import { ILinesDiffComputer, ILinesDiffComputerOptions, LineRangeMapping, LinesDiff, RangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; +import { ILinesDiffComputer, ILinesDiffComputerOptions, LineRangeMapping, LinesDiff, MovedText, RangeMapping, SimpleLineRangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; export class StandardLinesDiffComputer implements ILinesDiffComputer { private readonly dynamicProgrammingDiffing = new DynamicProgrammingDiffing(); @@ -116,7 +116,41 @@ export class StandardLinesDiffComputer implements ILinesDiffComputer { scanForWhitespaceChanges(originalLines.length - seq1LastStart); const changes = lineRangeMappingFromRangeMappings(alignments, originalLines, modifiedLines); - return new LinesDiff(changes, hitTimeout); + + const moves: MovedText[] = []; + if (options.computeMoves) { + const deletions = changes + .filter(c => c.modifiedRange.isEmpty && c.originalRange.length >= 3) + .map(d => new LineRangeFragment(d.originalRange, originalLines)); + const insertions = new Set(changes + .filter(c => c.originalRange.isEmpty && c.modifiedRange.length >= 3) + .map(d => new LineRangeFragment(d.modifiedRange, modifiedLines))); + + for (const deletion of deletions) { + let highestSimilarity = -1; + let best: LineRangeFragment | undefined; + for (const insertion of insertions) { + const similarity = deletion.computeSimilarity(insertion); + if (similarity > highestSimilarity) { + highestSimilarity = similarity; + best = insertion; + } + } + + if (highestSimilarity > 0.90 && best) { + const moveChanges = this.refineDiff(originalLines, modifiedLines, new SequenceDiff( + new OffsetRange(deletion.range.startLineNumber - 1, deletion.range.endLineNumberExclusive - 1), + new OffsetRange(best.range.startLineNumber - 1, best.range.endLineNumberExclusive - 1), + ), timeout, considerWhitespaceChanges); + const mappings = lineRangeMappingFromRangeMappings(moveChanges.mappings, originalLines, modifiedLines); + + insertions.delete(best); + moves.push(new MovedText(new SimpleLineRangeMapping(deletion.range, best.range), mappings)); + } + } + } + + return new LinesDiff(changes, moves, hitTimeout); } private refineDiff(originalLines: string[], modifiedLines: string[], diff: SequenceDiff, timeout: ITimeout, considerWhitespaceChanges: boolean): { mappings: RangeMapping[]; hitTimeout: boolean } { @@ -467,8 +501,8 @@ class Slice implements ISequence { } } - const offsetOfPrevLineBreak = i === 0 ? 0 : this.firstCharOffsetByLineMinusOne[i - 1]; - return new Position(this.lineRange.start + i + 1, offset - offsetOfPrevLineBreak + 1 + this.offsetByLine[i]); + const offsetOfFirstCharInLine = i === 0 ? 0 : this.firstCharOffsetByLineMinusOne[i - 1]; + return new Position(this.lineRange.start + i + 1, offset - offsetOfFirstCharInLine + 1 + this.offsetByLine[i]); } public translateRange(range: OffsetRange): Range { @@ -558,3 +592,47 @@ function getCategory(charCode: number): CharBoundaryCategory { function isSpace(charCode: number): boolean { return charCode === CharCode.Space || charCode === CharCode.Tab; } + +const chrKeys = new Map(); +function getKey(chr: string): number { + let key = chrKeys.get(chr); + if (key === undefined) { + key = chrKeys.size; + chrKeys.set(chr, key); + } + return key; +} + +class LineRangeFragment { + private readonly totalCount: number; + private readonly histogram: number[] = []; + constructor( + public readonly range: LineRange, + public readonly lines: string[], + ) { + let counter = 0; + for (let i = range.startLineNumber - 1; i < range.endLineNumberExclusive - 1; i++) { + const line = lines[i]; + for (let j = 0; j < line.length; j++) { + counter++; + const chr = line[j]; + const key = getKey(chr); + this.histogram[key] = (this.histogram[key] || 0) + 1; + } + counter++; + const key = getKey('\n'); + this.histogram[key] = (this.histogram[key] || 0) + 1; + } + + this.totalCount = counter; + } + + public computeSimilarity(other: LineRangeFragment): number { + let sumDifferences = 0; + const maxLength = Math.max(this.histogram.length, other.histogram.length); + for (let i = 0; i < maxLength; i++) { + sumDifferences += Math.abs((this.histogram[i] ?? 0) - (other.histogram[i] ?? 0)); + } + return 1 - (sumDifferences / (this.totalCount + other.totalCount)); + } +} diff --git a/src/vs/editor/common/services/editorSimpleWorker.ts b/src/vs/editor/common/services/editorSimpleWorker.ts index 55b923bf635..298a16f44b4 100644 --- a/src/vs/editor/common/services/editorSimpleWorker.ts +++ b/src/vs/editor/common/services/editorSimpleWorker.ts @@ -15,13 +15,13 @@ import { ensureValidWordDefinition, getWordAtText, IWordAtPosition } from 'vs/ed import { IColorInformation, IInplaceReplaceSupportResult, ILink, TextEdit } from 'vs/editor/common/languages'; import { ILinkComputerTarget, computeLinks } from 'vs/editor/common/languages/linkComputer'; import { BasicInplaceReplace } from 'vs/editor/common/languages/supports/inplaceReplaceSupport'; -import { DiffAlgorithmName, IDiffComputationResult, IUnicodeHighlightsResult } from 'vs/editor/common/services/editorWorker'; +import { DiffAlgorithmName, IDiffComputationResult, ILineChange, IUnicodeHighlightsResult } from 'vs/editor/common/services/editorWorker'; import { createMonacoBaseAPI } from 'vs/editor/common/services/editorBaseApi'; import { IEditorWorkerHost } from 'vs/editor/common/services/editorWorkerHost'; import { StopWatch } from 'vs/base/common/stopwatch'; import { UnicodeTextModelHighlighter, UnicodeHighlighterOptions } from 'vs/editor/common/services/unicodeTextModelHighlighter'; import { DiffComputer, IChange } from 'vs/editor/common/diff/smartLinesDiffComputer'; -import { ILinesDiffComputer, ILinesDiffComputerOptions } from 'vs/editor/common/diff/linesDiffComputer'; +import { ILinesDiffComputer, ILinesDiffComputerOptions, LineRangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; import { linesDiffComputers } from 'vs/editor/common/diff/linesDiffComputers'; import { createProxyObject, getAllMethodNames } from 'vs/base/common/objects'; import { IDocumentDiffProviderOptions } from 'vs/editor/common/diff/documentDiffProvider'; @@ -422,10 +422,8 @@ export class EditorSimpleWorker implements IRequestHandler, IDisposable { const identical = (result.changes.length > 0 ? false : this._modelsAreIdentical(originalTextModel, modifiedTextModel)); - return { - identical, - quitEarly: result.hitTimeout, - changes: result.changes.map(m => ([m.originalRange.startLineNumber, m.originalRange.endLineNumberExclusive, m.modifiedRange.startLineNumber, m.modifiedRange.endLineNumberExclusive, m.innerChanges?.map(m => [ + function getLineChanges(changes: readonly LineRangeMapping[]): ILineChange[] { + return changes.map(m => ([m.originalRange.startLineNumber, m.originalRange.endLineNumberExclusive, m.modifiedRange.startLineNumber, m.modifiedRange.endLineNumberExclusive, m.innerChanges?.map(m => [ m.originalRange.startLineNumber, m.originalRange.startColumn, m.originalRange.endLineNumber, @@ -434,7 +432,20 @@ export class EditorSimpleWorker implements IRequestHandler, IDisposable { m.modifiedRange.startColumn, m.modifiedRange.endLineNumber, m.modifiedRange.endColumn, - ])])) + ])])); + } + + return { + identical, + quitEarly: result.hitTimeout, + changes: getLineChanges(result.changes), + moves: result.moves.map(m => ([ + m.lineRangeMapping.originalRange.startLineNumber, + m.lineRangeMapping.originalRange.endLineNumberExclusive, + m.lineRangeMapping.modifiedRange.startLineNumber, + m.lineRangeMapping.modifiedRange.endLineNumberExclusive, + getLineChanges(m.changes) + ])), }; } diff --git a/src/vs/editor/common/services/editorWorker.ts b/src/vs/editor/common/services/editorWorker.ts index f5ec962c6dd..9038e313a9c 100644 --- a/src/vs/editor/common/services/editorWorker.ts +++ b/src/vs/editor/common/services/editorWorker.ts @@ -42,6 +42,7 @@ export interface IDiffComputationResult { quitEarly: boolean; changes: ILineChange[]; identical: boolean; + moves: ITextMove[]; } export type ILineChange = [ @@ -64,6 +65,14 @@ export type ICharChange = [ modifiedEndColumn: number, ]; +export type ITextMove = [ + originalStartLine: number, + originalEndLine: number, + modifiedStartLine: number, + modifiedEndLine: number, + changes: ILineChange[], +]; + export interface IUnicodeHighlightsResult { ranges: IRange[]; hasMore: boolean; diff --git a/src/vs/editor/standalone/browser/standaloneEditor.ts b/src/vs/editor/standalone/browser/standaloneEditor.ts index db445454176..4ff53468f06 100644 --- a/src/vs/editor/standalone/browser/standaloneEditor.ts +++ b/src/vs/editor/standalone/browser/standaloneEditor.ts @@ -34,7 +34,7 @@ import { EditorCommand, ServicesAccessor } from 'vs/editor/browser/editorExtensi import { IMenuItem, MenuRegistry, MenuId } from 'vs/platform/actions/common/actions'; import { ContextKeyExpr } from 'vs/platform/contextkey/common/contextkey'; import { PLAINTEXT_LANGUAGE_ID } from 'vs/editor/common/languages/modesRegistry'; -import { LineRangeMapping, RangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; +import { LineRangeMapping, MovedText, RangeMapping, SimpleLineRangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; import { LineRange } from 'vs/editor/common/core/lineRange'; import { EditorZoom } from 'vs/editor/common/config/editorZoom'; import { IOpenerService } from 'vs/platform/opener/common/opener'; @@ -586,6 +586,8 @@ export function createMonacoEditorAPI(): typeof monaco.editor { LineRangeMapping: LineRangeMapping, RangeMapping: RangeMapping, EditorZoom: EditorZoom, + MovedText: MovedText, + SimpleLineRangeMapping: SimpleLineRangeMapping, // vars EditorType: EditorType, diff --git a/src/vs/editor/test/common/services/editorSimpleWorker.test.ts b/src/vs/editor/test/common/services/editorSimpleWorker.test.ts index 280d4c871d7..f2cb9374f87 100644 --- a/src/vs/editor/test/common/services/editorSimpleWorker.test.ts +++ b/src/vs/editor/test/common/services/editorSimpleWorker.test.ts @@ -149,7 +149,7 @@ suite('EditorSimpleWorker', () => { const smallerEdits = await worker.computeHumanReadableDiff( model.uri.toString(), edits, - { ignoreTrimWhitespace: false, maxComputationTimeMs: 0 } + { ignoreTrimWhitespace: false, maxComputationTimeMs: 0, computeMoves: false } ); const t1 = applyEdits(model.getValue(), edits); diff --git a/src/vs/editor/test/node/diffing/diffingFixture.test.ts b/src/vs/editor/test/node/diffing/diffingFixture.test.ts index 6fcda40b612..d6ad0b18bb3 100644 --- a/src/vs/editor/test/node/diffing/diffingFixture.test.ts +++ b/src/vs/editor/test/node/diffing/diffingFixture.test.ts @@ -7,6 +7,7 @@ import * as assert from 'assert'; import { readdirSync, readFileSync, existsSync, writeFileSync, rmSync } from 'fs'; import { join, resolve } from 'path'; import { FileAccess } from 'vs/base/common/network'; +import { LineRangeMapping } from 'vs/editor/common/diff/linesDiffComputer'; import { SmartLinesDiffComputer } from 'vs/editor/common/diff/smartLinesDiffComputer'; import { StandardLinesDiffComputer } from 'vs/editor/common/diff/standardLinesDiffComputer'; @@ -31,20 +32,32 @@ suite('diff fixtures', () => { const diffingAlgo = diffingAlgoName === 'legacy' ? new SmartLinesDiffComputer() : new StandardLinesDiffComputer(); - const diff = diffingAlgo.computeDiff(firstContentLines, secondContentLines, { ignoreTrimWhitespace: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER }); + const diff = diffingAlgo.computeDiff(firstContentLines, secondContentLines, { ignoreTrimWhitespace: false, maxComputationTimeMs: Number.MAX_SAFE_INTEGER, computeMoves: false }); - const actualDiffingResult: DiffingResult = { - original: { content: firstContent, fileName: `./${firstFileName}` }, - modified: { content: secondContent, fileName: `./${secondFileName}` }, - diffs: diff.changes.map(c => ({ + function getDiffs(changes: readonly LineRangeMapping[]): IDetailedDiff[] { + return changes.map(c => ({ originalRange: c.originalRange.toString(), modifiedRange: c.modifiedRange.toString(), innerChanges: c.innerChanges?.map(c => ({ originalRange: c.originalRange.toString(), modifiedRange: c.modifiedRange.toString(), })) || null + })); + } + + const actualDiffingResult: DiffingResult = { + original: { content: firstContent, fileName: `./${firstFileName}` }, + modified: { content: secondContent, fileName: `./${secondFileName}` }, + diffs: getDiffs(diff.changes), + moves: diff.moves.map(v => ({ + originalRange: v.lineRangeMapping.originalRange.toString(), + modifiedRange: v.lineRangeMapping.modifiedRange.toString(), + changes: getDiffs(v.changes), })) }; + if (actualDiffingResult.moves?.length === 0) { + delete actualDiffingResult.moves; + } const expectedFilePath = join(folderPath, `${diffingAlgoName}.expected.diff.json`); const invalidFilePath = join(folderPath, `${diffingAlgoName}.invalid.diff.json`); @@ -90,8 +103,8 @@ suite('diff fixtures', () => { } } - test(`uiae`, () => { - runTest('subword', 'advanced'); + test(`test`, () => { + runTest('move-1', 'advanced'); }); for (const folder of folders) { @@ -108,6 +121,7 @@ interface DiffingResult { modified: { content: string; fileName: string }; diffs: IDetailedDiff[]; + moves?: IMoveInfo[]; } interface IDetailedDiff { @@ -120,3 +134,10 @@ interface IDiff { originalRange: string; // [1,18 -> 1,19] modifiedRange: string; // [1,18 -> 1,19] } + +interface IMoveInfo { + originalRange: string; // [startLineNumber, endLineNumberExclusive) + modifiedRange: string; // [startLineNumber, endLineNumberExclusive) + + changes?: IDetailedDiff[]; +} diff --git a/src/vs/editor/test/node/diffing/fixtures/move-1/1.tst b/src/vs/editor/test/node/diffing/fixtures/move-1/1.tst new file mode 100644 index 00000000000..0a33e795f61 --- /dev/null +++ b/src/vs/editor/test/node/diffing/fixtures/move-1/1.tst @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as arrays from 'vs/base/common/arrays'; +import { IdleDeadline, runWhenIdle } from 'vs/base/common/async'; +import { BugIndicatingError, onUnexpectedError } from 'vs/base/common/errors'; +import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { setTimeout0 } from 'vs/base/common/platform'; +import { StopWatch } from 'vs/base/common/stopwatch'; +import { countEOL } from 'vs/editor/common/core/eolCounter'; +import { Position } from 'vs/editor/common/core/position'; +import { IRange } from 'vs/editor/common/core/range'; +import { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes'; +import { EncodedTokenizationResult, IBackgroundTokenizationStore, IBackgroundTokenizer, ILanguageIdCodec, IState, ITokenizationSupport, TokenizationRegistry } from 'vs/editor/common/languages'; +import { nullTokenizeEncoded } from 'vs/editor/common/languages/nullTokenize'; +import { ITextModel } from 'vs/editor/common/model'; +import { TextModel } from 'vs/editor/common/model/textModel'; +import { TokenizationTextModelPart } from 'vs/editor/common/model/tokenizationTextModelPart'; +import { IModelContentChangedEvent, IModelLanguageChangedEvent } from 'vs/editor/common/textModelEvents'; +import { ContiguousMultilineTokensBuilder } from 'vs/editor/common/tokens/contiguousMultilineTokensBuilder'; +import { LineTokens } from 'vs/editor/common/tokens/lineTokens'; + +const enum Constants { + CHEAP_TOKENIZATION_LENGTH_LIMIT = 2048 +} + +/** + * An array that avoids being sparse by always + * filling up unused indices with a default value. + */ +export class ContiguousGrowingArray { + + private _store: T[] = []; + + constructor( + private readonly _default: T + ) { } + + public get(index: number): T { + if (index < this._store.length) { + return this._store[index]; + } + return this._default; + } + + public set(index: number, value: T): void { + while (index >= this._store.length) { + this._store[this._store.length] = this._default; + } + this._store[index] = value; + } + + // TODO have `replace` instead of `delete` and `insert` + public delete(deleteIndex: number, deleteCount: number): void { + if (deleteCount === 0 || deleteIndex >= this._store.length) { + return; + } + this._store.splice(deleteIndex, deleteCount); + } + + public insert(insertIndex: number, insertCount: number): void { + if (insertCount === 0 || insertIndex >= this._store.length) { + return; + } + const arr: T[] = []; + for (let i = 0; i < insertCount; i++) { + arr[i] = this._default; + } + this._store = arrays.arrayInsert(this._store, insertIndex, arr); + } +} + +/** + * Stores the states at the start of each line and keeps track of which lines + * must be re-tokenized. Also uses state equality to quickly validate lines + * that don't need to be re-tokenized. + * + * For example, when typing on a line, the line gets marked as needing to be tokenized. + * Once the line is tokenized, the end state is checked for equality against the begin + * state of the next line. If the states are equal, tokenization doesn't need to run + * again over the rest of the file. If the states are not equal, the next line gets marked + * as needing to be tokenized. + */ +export class TokenizationStateStore { + requestTokens(startLineNumber: number, endLineNumberExclusive: number): void { + for (let lineNumber = startLineNumber; lineNumber < endLineNumberExclusive; lineNumber++) { + this._stateStore.markMustBeTokenized(lineNumber - 1); + } + } +} diff --git a/src/vs/editor/test/node/diffing/fixtures/move-1/2.tst b/src/vs/editor/test/node/diffing/fixtures/move-1/2.tst new file mode 100644 index 00000000000..161b3eddcca --- /dev/null +++ b/src/vs/editor/test/node/diffing/fixtures/move-1/2.tst @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as arrays from 'vs/base/common/arrays'; +import { IdleDeadline, runWhenIdle } from 'vs/base/common/async'; +import { BugIndicatingError, onUnexpectedError } from 'vs/base/common/errors'; +import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { setTimeout0 } from 'vs/base/common/platform'; +import { StopWatch } from 'vs/base/common/stopwatch'; +import { countEOL } from 'vs/editor/common/core/eolCounter'; +import { Position } from 'vs/editor/common/core/position'; +import { IRange } from 'vs/editor/common/core/range'; +import { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes'; +import { EncodedTokenizationResult, IBackgroundTokenizationStore, IBackgroundTokenizer, ILanguageIdCodec, IState, ITokenizationSupport, TokenizationRegistry } from 'vs/editor/common/languages'; +import { nullTokenizeEncoded } from 'vs/editor/common/languages/nullTokenize'; +import { ITextModel } from 'vs/editor/common/model'; +import { TextModel } from 'vs/editor/common/model/textModel'; +import { TokenizationTextModelPart } from 'vs/editor/common/model/tokenizationTextModelPart'; +import { IModelContentChangedEvent, IModelLanguageChangedEvent } from 'vs/editor/common/textModelEvents'; +import { ContiguousMultilineTokensBuilder } from 'vs/editor/common/tokens/contiguousMultilineTokensBuilder'; +import { LineTokens } from 'vs/editor/common/tokens/lineTokens'; + +/** + * An array that avoids being sparse by always + * filling up unused indices with a default value. + */ +export class ContiguousGrowingArray { + + private _store: T[] = []; + + constructor( + private readonly _default: T + ) { } + + public get(index: number): T { + if (index < this._store.length) { + return this._store[index]; + } + return this._default; + } + + public set(index: number, value: T): void { + while (index >= this._store.length) { + this._store[this._store.length] = this._default; + } + this._store[index] = value; + } + + // TODO have `replace` instead of `delete` and `insert` + public delete(deleteIndex: number, deleteCount: number): void { + if (deleteCount === 0 || deleteIndex >= this._store.length) { + return; + } + this._store.splice(deleteIndex, deleteCount); + } + + public insert(insertIndex: number, insertCount: number): void { + if (insertCount === 0 || insertIndex >= this._store.length) { + return; + } + const arr: T[] = []; + for (let i = 0; i < insertCount; i++) { + arr[i] = this._default; + } + this._store = arrays.arrayInsert(this._store, insertIndex, arr); + } +} + +const enum Constants { + CHEAP_TOKENIZATION_LENGTH_LIMIT = 1024 +} + +/** + * Stores the states at the start of each line and keeps track of which lines + * must be re-tokenized. Also uses state equality to quickly validate lines + * that don't need to be re-tokenized. + * + * For example, when typing on a line, the line gets marked as needing to be tokenized. + * Once the line is tokenized, the end state is checked for equality against the begin + * state of the next line. If the states are equal, tokenization doesn't need to run + * again over the rest of the file. If the states are not equal, the next line gets marked + * as needing to be tokenized. + */ +export class TokenizationStateStore { + requestTokens(startLineNumber: number, endLineNumberExclusive: number): void { + for (let lineNumber = startLineNumber; lineNumber < endLineNumberExclusive; lineNumber++) { + this._stateStore.markMustBeTokenized(lineNumber - 1); + } + } +} diff --git a/src/vs/editor/test/node/diffing/fixtures/move-1/advanced.expected.diff.json b/src/vs/editor/test/node/diffing/fixtures/move-1/advanced.expected.diff.json new file mode 100644 index 00000000000..73f6bfc728f --- /dev/null +++ b/src/vs/editor/test/node/diffing/fixtures/move-1/advanced.expected.diff.json @@ -0,0 +1,32 @@ +{ + "original": { + "content": "/*---------------------------------------------------------------------------------------------\n * Copyright (c) Microsoft Corporation. All rights reserved.\n * Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\nimport * as arrays from 'vs/base/common/arrays';\nimport { IdleDeadline, runWhenIdle } from 'vs/base/common/async';\nimport { BugIndicatingError, onUnexpectedError } from 'vs/base/common/errors';\nimport { Disposable, MutableDisposable } from 'vs/base/common/lifecycle';\nimport { setTimeout0 } from 'vs/base/common/platform';\nimport { StopWatch } from 'vs/base/common/stopwatch';\nimport { countEOL } from 'vs/editor/common/core/eolCounter';\nimport { Position } from 'vs/editor/common/core/position';\nimport { IRange } from 'vs/editor/common/core/range';\nimport { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes';\nimport { EncodedTokenizationResult, IBackgroundTokenizationStore, IBackgroundTokenizer, ILanguageIdCodec, IState, ITokenizationSupport, TokenizationRegistry } from 'vs/editor/common/languages';\nimport { nullTokenizeEncoded } from 'vs/editor/common/languages/nullTokenize';\nimport { ITextModel } from 'vs/editor/common/model';\nimport { TextModel } from 'vs/editor/common/model/textModel';\nimport { TokenizationTextModelPart } from 'vs/editor/common/model/tokenizationTextModelPart';\nimport { IModelContentChangedEvent, IModelLanguageChangedEvent } from 'vs/editor/common/textModelEvents';\nimport { ContiguousMultilineTokensBuilder } from 'vs/editor/common/tokens/contiguousMultilineTokensBuilder';\nimport { LineTokens } from 'vs/editor/common/tokens/lineTokens';\n\nconst enum Constants {\n\tCHEAP_TOKENIZATION_LENGTH_LIMIT = 2048\n}\n\n/**\n * An array that avoids being sparse by always\n * filling up unused indices with a default value.\n */\nexport class ContiguousGrowingArray {\n\n\tprivate _store: T[] = [];\n\n\tconstructor(\n\t\tprivate readonly _default: T\n\t) { }\n\n\tpublic get(index: number): T {\n\t\tif (index < this._store.length) {\n\t\t\treturn this._store[index];\n\t\t}\n\t\treturn this._default;\n\t}\n\n\tpublic set(index: number, value: T): void {\n\t\twhile (index >= this._store.length) {\n\t\t\tthis._store[this._store.length] = this._default;\n\t\t}\n\t\tthis._store[index] = value;\n\t}\n\n\t// TODO have `replace` instead of `delete` and `insert`\n\tpublic delete(deleteIndex: number, deleteCount: number): void {\n\t\tif (deleteCount === 0 || deleteIndex >= this._store.length) {\n\t\t\treturn;\n\t\t}\n\t\tthis._store.splice(deleteIndex, deleteCount);\n\t}\n\n\tpublic insert(insertIndex: number, insertCount: number): void {\n\t\tif (insertCount === 0 || insertIndex >= this._store.length) {\n\t\t\treturn;\n\t\t}\n\t\tconst arr: T[] = [];\n\t\tfor (let i = 0; i < insertCount; i++) {\n\t\t\tarr[i] = this._default;\n\t\t}\n\t\tthis._store = arrays.arrayInsert(this._store, insertIndex, arr);\n\t}\n}\n\n/**\n * Stores the states at the start of each line and keeps track of which lines\n * must be re-tokenized. Also uses state equality to quickly validate lines\n * that don't need to be re-tokenized.\n *\n * For example, when typing on a line, the line gets marked as needing to be tokenized.\n * Once the line is tokenized, the end state is checked for equality against the begin\n * state of the next line. If the states are equal, tokenization doesn't need to run\n * again over the rest of the file. If the states are not equal, the next line gets marked\n * as needing to be tokenized.\n */\nexport class TokenizationStateStore {\n\trequestTokens(startLineNumber: number, endLineNumberExclusive: number): void {\n\t\tfor (let lineNumber = startLineNumber; lineNumber < endLineNumberExclusive; lineNumber++) {\n\t\t\tthis._stateStore.markMustBeTokenized(lineNumber - 1);\n\t\t}\n\t}\n}\n", + "fileName": "./1.tst" + }, + "modified": { + "content": "/*---------------------------------------------------------------------------------------------\n * Copyright (c) Microsoft Corporation. All rights reserved.\n * Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\nimport * as arrays from 'vs/base/common/arrays';\nimport { IdleDeadline, runWhenIdle } from 'vs/base/common/async';\nimport { BugIndicatingError, onUnexpectedError } from 'vs/base/common/errors';\nimport { Disposable, MutableDisposable } from 'vs/base/common/lifecycle';\nimport { setTimeout0 } from 'vs/base/common/platform';\nimport { StopWatch } from 'vs/base/common/stopwatch';\nimport { countEOL } from 'vs/editor/common/core/eolCounter';\nimport { Position } from 'vs/editor/common/core/position';\nimport { IRange } from 'vs/editor/common/core/range';\nimport { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes';\nimport { EncodedTokenizationResult, IBackgroundTokenizationStore, IBackgroundTokenizer, ILanguageIdCodec, IState, ITokenizationSupport, TokenizationRegistry } from 'vs/editor/common/languages';\nimport { nullTokenizeEncoded } from 'vs/editor/common/languages/nullTokenize';\nimport { ITextModel } from 'vs/editor/common/model';\nimport { TextModel } from 'vs/editor/common/model/textModel';\nimport { TokenizationTextModelPart } from 'vs/editor/common/model/tokenizationTextModelPart';\nimport { IModelContentChangedEvent, IModelLanguageChangedEvent } from 'vs/editor/common/textModelEvents';\nimport { ContiguousMultilineTokensBuilder } from 'vs/editor/common/tokens/contiguousMultilineTokensBuilder';\nimport { LineTokens } from 'vs/editor/common/tokens/lineTokens';\n\n/**\n * An array that avoids being sparse by always\n * filling up unused indices with a default value.\n */\nexport class ContiguousGrowingArray {\n\n\tprivate _store: T[] = [];\n\n\tconstructor(\n\t\tprivate readonly _default: T\n\t) { }\n\n\tpublic get(index: number): T {\n\t\tif (index < this._store.length) {\n\t\t\treturn this._store[index];\n\t\t}\n\t\treturn this._default;\n\t}\n\n\tpublic set(index: number, value: T): void {\n\t\twhile (index >= this._store.length) {\n\t\t\tthis._store[this._store.length] = this._default;\n\t\t}\n\t\tthis._store[index] = value;\n\t}\n\n\t// TODO have `replace` instead of `delete` and `insert`\n\tpublic delete(deleteIndex: number, deleteCount: number): void {\n\t\tif (deleteCount === 0 || deleteIndex >= this._store.length) {\n\t\t\treturn;\n\t\t}\n\t\tthis._store.splice(deleteIndex, deleteCount);\n\t}\n\n\tpublic insert(insertIndex: number, insertCount: number): void {\n\t\tif (insertCount === 0 || insertIndex >= this._store.length) {\n\t\t\treturn;\n\t\t}\n\t\tconst arr: T[] = [];\n\t\tfor (let i = 0; i < insertCount; i++) {\n\t\t\tarr[i] = this._default;\n\t\t}\n\t\tthis._store = arrays.arrayInsert(this._store, insertIndex, arr);\n\t}\n}\n\nconst enum Constants {\n\tCHEAP_TOKENIZATION_LENGTH_LIMIT = 1024\n}\n\n/**\n * Stores the states at the start of each line and keeps track of which lines\n * must be re-tokenized. Also uses state equality to quickly validate lines\n * that don't need to be re-tokenized.\n *\n * For example, when typing on a line, the line gets marked as needing to be tokenized.\n * Once the line is tokenized, the end state is checked for equality against the begin\n * state of the next line. If the states are equal, tokenization doesn't need to run\n * again over the rest of the file. If the states are not equal, the next line gets marked\n * as needing to be tokenized.\n */\nexport class TokenizationStateStore {\n\trequestTokens(startLineNumber: number, endLineNumberExclusive: number): void {\n\t\tfor (let lineNumber = startLineNumber; lineNumber < endLineNumberExclusive; lineNumber++) {\n\t\t\tthis._stateStore.markMustBeTokenized(lineNumber - 1);\n\t\t}\n\t}\n}\n", + "fileName": "./2.tst" + }, + "diffs": [ + { + "originalRange": "[24,28)", + "modifiedRange": "[24,24)", + "innerChanges": [ + { + "originalRange": "[24,1 -> 28,1]", + "modifiedRange": "[24,1 -> 24,1]" + } + ] + }, + { + "originalRange": "[74,74)", + "modifiedRange": "[70,74)", + "innerChanges": [ + { + "originalRange": "[74,1 -> 74,1]", + "modifiedRange": "[70,1 -> 74,1]" + } + ] + } + ] +} \ No newline at end of file diff --git a/src/vs/editor/test/node/diffing/fixtures/move-1/legacy.expected.diff.json b/src/vs/editor/test/node/diffing/fixtures/move-1/legacy.expected.diff.json new file mode 100644 index 00000000000..42fd3c23abb --- /dev/null +++ b/src/vs/editor/test/node/diffing/fixtures/move-1/legacy.expected.diff.json @@ -0,0 +1,22 @@ +{ + "original": { + "content": "/*---------------------------------------------------------------------------------------------\n * Copyright (c) Microsoft Corporation. All rights reserved.\n * Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\nimport * as arrays from 'vs/base/common/arrays';\nimport { IdleDeadline, runWhenIdle } from 'vs/base/common/async';\nimport { BugIndicatingError, onUnexpectedError } from 'vs/base/common/errors';\nimport { Disposable, MutableDisposable } from 'vs/base/common/lifecycle';\nimport { setTimeout0 } from 'vs/base/common/platform';\nimport { StopWatch } from 'vs/base/common/stopwatch';\nimport { countEOL } from 'vs/editor/common/core/eolCounter';\nimport { Position } from 'vs/editor/common/core/position';\nimport { IRange } from 'vs/editor/common/core/range';\nimport { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes';\nimport { EncodedTokenizationResult, IBackgroundTokenizationStore, IBackgroundTokenizer, ILanguageIdCodec, IState, ITokenizationSupport, TokenizationRegistry } from 'vs/editor/common/languages';\nimport { nullTokenizeEncoded } from 'vs/editor/common/languages/nullTokenize';\nimport { ITextModel } from 'vs/editor/common/model';\nimport { TextModel } from 'vs/editor/common/model/textModel';\nimport { TokenizationTextModelPart } from 'vs/editor/common/model/tokenizationTextModelPart';\nimport { IModelContentChangedEvent, IModelLanguageChangedEvent } from 'vs/editor/common/textModelEvents';\nimport { ContiguousMultilineTokensBuilder } from 'vs/editor/common/tokens/contiguousMultilineTokensBuilder';\nimport { LineTokens } from 'vs/editor/common/tokens/lineTokens';\n\nconst enum Constants {\n\tCHEAP_TOKENIZATION_LENGTH_LIMIT = 2048\n}\n\n/**\n * An array that avoids being sparse by always\n * filling up unused indices with a default value.\n */\nexport class ContiguousGrowingArray {\n\n\tprivate _store: T[] = [];\n\n\tconstructor(\n\t\tprivate readonly _default: T\n\t) { }\n\n\tpublic get(index: number): T {\n\t\tif (index < this._store.length) {\n\t\t\treturn this._store[index];\n\t\t}\n\t\treturn this._default;\n\t}\n\n\tpublic set(index: number, value: T): void {\n\t\twhile (index >= this._store.length) {\n\t\t\tthis._store[this._store.length] = this._default;\n\t\t}\n\t\tthis._store[index] = value;\n\t}\n\n\t// TODO have `replace` instead of `delete` and `insert`\n\tpublic delete(deleteIndex: number, deleteCount: number): void {\n\t\tif (deleteCount === 0 || deleteIndex >= this._store.length) {\n\t\t\treturn;\n\t\t}\n\t\tthis._store.splice(deleteIndex, deleteCount);\n\t}\n\n\tpublic insert(insertIndex: number, insertCount: number): void {\n\t\tif (insertCount === 0 || insertIndex >= this._store.length) {\n\t\t\treturn;\n\t\t}\n\t\tconst arr: T[] = [];\n\t\tfor (let i = 0; i < insertCount; i++) {\n\t\t\tarr[i] = this._default;\n\t\t}\n\t\tthis._store = arrays.arrayInsert(this._store, insertIndex, arr);\n\t}\n}\n\n/**\n * Stores the states at the start of each line and keeps track of which lines\n * must be re-tokenized. Also uses state equality to quickly validate lines\n * that don't need to be re-tokenized.\n *\n * For example, when typing on a line, the line gets marked as needing to be tokenized.\n * Once the line is tokenized, the end state is checked for equality against the begin\n * state of the next line. If the states are equal, tokenization doesn't need to run\n * again over the rest of the file. If the states are not equal, the next line gets marked\n * as needing to be tokenized.\n */\nexport class TokenizationStateStore {\n\trequestTokens(startLineNumber: number, endLineNumberExclusive: number): void {\n\t\tfor (let lineNumber = startLineNumber; lineNumber < endLineNumberExclusive; lineNumber++) {\n\t\t\tthis._stateStore.markMustBeTokenized(lineNumber - 1);\n\t\t}\n\t}\n}\n", + "fileName": "./1.tst" + }, + "modified": { + "content": "/*---------------------------------------------------------------------------------------------\n * Copyright (c) Microsoft Corporation. All rights reserved.\n * Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\nimport * as arrays from 'vs/base/common/arrays';\nimport { IdleDeadline, runWhenIdle } from 'vs/base/common/async';\nimport { BugIndicatingError, onUnexpectedError } from 'vs/base/common/errors';\nimport { Disposable, MutableDisposable } from 'vs/base/common/lifecycle';\nimport { setTimeout0 } from 'vs/base/common/platform';\nimport { StopWatch } from 'vs/base/common/stopwatch';\nimport { countEOL } from 'vs/editor/common/core/eolCounter';\nimport { Position } from 'vs/editor/common/core/position';\nimport { IRange } from 'vs/editor/common/core/range';\nimport { StandardTokenType } from 'vs/editor/common/encodedTokenAttributes';\nimport { EncodedTokenizationResult, IBackgroundTokenizationStore, IBackgroundTokenizer, ILanguageIdCodec, IState, ITokenizationSupport, TokenizationRegistry } from 'vs/editor/common/languages';\nimport { nullTokenizeEncoded } from 'vs/editor/common/languages/nullTokenize';\nimport { ITextModel } from 'vs/editor/common/model';\nimport { TextModel } from 'vs/editor/common/model/textModel';\nimport { TokenizationTextModelPart } from 'vs/editor/common/model/tokenizationTextModelPart';\nimport { IModelContentChangedEvent, IModelLanguageChangedEvent } from 'vs/editor/common/textModelEvents';\nimport { ContiguousMultilineTokensBuilder } from 'vs/editor/common/tokens/contiguousMultilineTokensBuilder';\nimport { LineTokens } from 'vs/editor/common/tokens/lineTokens';\n\n/**\n * An array that avoids being sparse by always\n * filling up unused indices with a default value.\n */\nexport class ContiguousGrowingArray {\n\n\tprivate _store: T[] = [];\n\n\tconstructor(\n\t\tprivate readonly _default: T\n\t) { }\n\n\tpublic get(index: number): T {\n\t\tif (index < this._store.length) {\n\t\t\treturn this._store[index];\n\t\t}\n\t\treturn this._default;\n\t}\n\n\tpublic set(index: number, value: T): void {\n\t\twhile (index >= this._store.length) {\n\t\t\tthis._store[this._store.length] = this._default;\n\t\t}\n\t\tthis._store[index] = value;\n\t}\n\n\t// TODO have `replace` instead of `delete` and `insert`\n\tpublic delete(deleteIndex: number, deleteCount: number): void {\n\t\tif (deleteCount === 0 || deleteIndex >= this._store.length) {\n\t\t\treturn;\n\t\t}\n\t\tthis._store.splice(deleteIndex, deleteCount);\n\t}\n\n\tpublic insert(insertIndex: number, insertCount: number): void {\n\t\tif (insertCount === 0 || insertIndex >= this._store.length) {\n\t\t\treturn;\n\t\t}\n\t\tconst arr: T[] = [];\n\t\tfor (let i = 0; i < insertCount; i++) {\n\t\t\tarr[i] = this._default;\n\t\t}\n\t\tthis._store = arrays.arrayInsert(this._store, insertIndex, arr);\n\t}\n}\n\nconst enum Constants {\n\tCHEAP_TOKENIZATION_LENGTH_LIMIT = 1024\n}\n\n/**\n * Stores the states at the start of each line and keeps track of which lines\n * must be re-tokenized. Also uses state equality to quickly validate lines\n * that don't need to be re-tokenized.\n *\n * For example, when typing on a line, the line gets marked as needing to be tokenized.\n * Once the line is tokenized, the end state is checked for equality against the begin\n * state of the next line. If the states are equal, tokenization doesn't need to run\n * again over the rest of the file. If the states are not equal, the next line gets marked\n * as needing to be tokenized.\n */\nexport class TokenizationStateStore {\n\trequestTokens(startLineNumber: number, endLineNumberExclusive: number): void {\n\t\tfor (let lineNumber = startLineNumber; lineNumber < endLineNumberExclusive; lineNumber++) {\n\t\t\tthis._stateStore.markMustBeTokenized(lineNumber - 1);\n\t\t}\n\t}\n}\n", + "fileName": "./2.tst" + }, + "diffs": [ + { + "originalRange": "[25,29)", + "modifiedRange": "[25,25)", + "innerChanges": null + }, + { + "originalRange": "[75,75)", + "modifiedRange": "[71,75)", + "innerChanges": null + } + ] +} \ No newline at end of file diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 1f8d14f0d45..1aeebd607de 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -2390,6 +2390,10 @@ declare namespace monaco.editor { * A diff computation should throw if it takes longer than this value. */ maxComputationTimeMs: number; + /** + * If set, the diff computation should compute moves in addition to insertions and deletions. + */ + computeMoves: boolean; } /** @@ -2407,7 +2411,12 @@ declare namespace monaco.editor { /** * Maps all modified line ranges in the original to the corresponding line ranges in the modified text model. */ - readonly changes: LineRangeMapping[]; + readonly changes: readonly LineRangeMapping[]; + /** + * Sorted by original line ranges. + * The original line ranges and the modified line ranges must be disjoint (but can be touching). + */ + readonly moves: readonly MovedText[]; } /** @@ -2415,6 +2424,7 @@ declare namespace monaco.editor { */ export class LineRange { static fromRange(range: Range): LineRange; + static subtract(a: LineRange, b: LineRange | undefined): LineRange[]; /** * @param lineRanges An array of sorted line ranges. */ @@ -2471,7 +2481,7 @@ declare namespace monaco.editor { * Maps a line range in the original text model to a line range in the modified text model. */ export class LineRangeMapping { - static inverse(mapping: LineRangeMapping[], originalLineCount: number, modifiedLineCount: number): LineRangeMapping[]; + static inverse(mapping: readonly LineRangeMapping[], originalLineCount: number, modifiedLineCount: number): LineRangeMapping[]; /** * The line range in the original text model. */ @@ -2507,6 +2517,24 @@ declare namespace monaco.editor { constructor(originalRange: Range, modifiedRange: Range); toString(): string; } + + export class MovedText { + readonly lineRangeMapping: SimpleLineRangeMapping; + /** + * The diff from the original text to the moved text. + * Must be contained in the original/modified line range. + * Can be empty if the text didn't change (only moved). + */ + readonly changes: readonly LineRangeMapping[]; + constructor(lineRangeMapping: SimpleLineRangeMapping, changes: readonly LineRangeMapping[]); + } + + export class SimpleLineRangeMapping { + readonly originalRange: LineRange; + readonly modifiedRange: LineRange; + constructor(originalRange: LineRange, modifiedRange: LineRange); + toString(): string; + } export interface IDimension { width: number; height: number; @@ -3911,6 +3939,10 @@ declare namespace monaco.editor { * Defaults to false. */ collapseUnchangedRegions?: boolean; + /** + * Defaults to false. + */ + showMoves?: boolean; }; } diff --git a/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorController.ts b/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorController.ts index eef78a013da..300b0d042ac 100644 --- a/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorController.ts +++ b/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorController.ts @@ -514,7 +514,7 @@ export class InteractiveEditorController implements IEditorContribution { const textModelNplus1 = this._modelService.createModel(createTextBufferFactoryFromSnapshot(this._activeSession.textModelN.createSnapshot()), null, undefined, true); textModelNplus1.applyEdits(editOperations); - const diff = await this._editorWorkerService.computeDiff(this._activeSession.textModel0.uri, textModelNplus1.uri, { ignoreTrimWhitespace: false, maxComputationTimeMs: 5000 }, 'advanced'); + const diff = await this._editorWorkerService.computeDiff(this._activeSession.textModel0.uri, textModelNplus1.uri, { ignoreTrimWhitespace: false, maxComputationTimeMs: 5000, computeMoves: false }, 'advanced'); this._activeSession.lastTextModelChanges = diff?.changes ?? []; textModelNplus1.dispose(); diff --git a/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorLivePreviewWidget.ts b/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorLivePreviewWidget.ts index d62f4f253f8..b97c2cebb54 100644 --- a/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorLivePreviewWidget.ts +++ b/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorLivePreviewWidget.ts @@ -150,7 +150,7 @@ export class InteractiveEditorLivePreviewWidget extends ZoneWidget { this._isVisible = true; } - private _updateFromChanges(range: Range, changes: LineRangeMapping[]): void { + private _updateFromChanges(range: Range, changes: readonly LineRangeMapping[]): void { assertType(this.editor.hasModel()); if (changes.length === 0 || this._session.textModel0.getValueLength() === 0) { @@ -174,7 +174,7 @@ export class InteractiveEditorLivePreviewWidget extends ZoneWidget { // --- inline diff - private _renderChangesWithInlineDiff(changes: LineRangeMapping[]) { + private _renderChangesWithInlineDiff(changes: readonly LineRangeMapping[]) { const original = this._session.textModel0; const decorations: IModelDeltaDecoration[] = []; @@ -221,7 +221,7 @@ export class InteractiveEditorLivePreviewWidget extends ZoneWidget { // --- full diff - private _renderChangesWithFullDiff(changes: LineRangeMapping[], range: Range) { + private _renderChangesWithFullDiff(changes: readonly LineRangeMapping[], range: Range) { const modified = this.editor.getModel()!; const ranges = this._computeHiddenRanges(modified, range, changes); @@ -250,7 +250,7 @@ export class InteractiveEditorLivePreviewWidget extends ZoneWidget { super.hide(); } - private _computeHiddenRanges(model: ITextModel, range: Range, changes: LineRangeMapping[]) { + private _computeHiddenRanges(model: ITextModel, range: Range, changes: readonly LineRangeMapping[]) { assertType(changes.length > 0); let originalLineRange = changes[0].originalRange; diff --git a/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorSession.ts b/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorSession.ts index c09f3f4339d..4cdb3455f03 100644 --- a/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorSession.ts +++ b/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorSession.ts @@ -110,7 +110,7 @@ export class Session { private _lastInput: string | undefined; private _lastExpansionState: ExpansionState | undefined; - private _lastTextModelChanges: LineRangeMapping[] | undefined; + private _lastTextModelChanges: readonly LineRangeMapping[] | undefined; private _isUnstashed: boolean = false; private readonly _exchange: SessionExchange[] = []; private readonly _startTime = new Date(); @@ -185,7 +185,7 @@ export class Session { return this._lastTextModelChanges ?? []; } - set lastTextModelChanges(changes: LineRangeMapping[]) { + set lastTextModelChanges(changes: readonly LineRangeMapping[]) { this._lastTextModelChanges = changes; } diff --git a/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorWidget.ts b/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorWidget.ts index fd288d24401..4cc8b890fe9 100644 --- a/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorWidget.ts +++ b/src/vs/workbench/contrib/interactiveEditor/browser/interactiveEditorWidget.ts @@ -554,7 +554,7 @@ export class InteractiveEditorWidget { // --- preview - showEditsPreview(textModelv0: ITextModel, edits: ISingleEditOperation[], changes: LineRangeMapping[]) { + showEditsPreview(textModelv0: ITextModel, edits: ISingleEditOperation[], changes: readonly LineRangeMapping[]) { if (changes.length === 0) { this.hideEditsPreview(); return; diff --git a/src/vs/workbench/contrib/mergeEditor/browser/model/diffComputer.ts b/src/vs/workbench/contrib/mergeEditor/browser/model/diffComputer.ts index c0fa8810c0b..0e3d81bca1d 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/model/diffComputer.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/model/diffComputer.ts @@ -41,6 +41,7 @@ export class MergeDiffComputer implements IMergeDiffComputer { { ignoreTrimWhitespace: false, maxComputationTimeMs: 0, + computeMoves: false, }, diffAlgorithm, ); diff --git a/src/vs/workbench/contrib/mergeEditor/test/browser/model.test.ts b/src/vs/workbench/contrib/mergeEditor/test/browser/model.test.ts index 5e9a7cba92d..f8044b0678b 100644 --- a/src/vs/workbench/contrib/mergeEditor/test/browser/model.test.ts +++ b/src/vs/workbench/contrib/mergeEditor/test/browser/model.test.ts @@ -286,7 +286,7 @@ class MergeModelInterface extends Disposable { const result = await linesDiffComputers.legacy.computeDiff( textModel1.getLinesContent(), textModel2.getLinesContent(), - { ignoreTrimWhitespace: false, maxComputationTimeMs: 10000 } + { ignoreTrimWhitespace: false, maxComputationTimeMs: 10000, computeMoves: false } ); const changes = result.changes.map(c => new DetailedLineRangeMapping(