From 9cb3ac3695c5886a763114002cb12bbab2167734 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Feb 2026 09:13:12 +0100 Subject: [PATCH 01/15] Fix `ConstantTimePrefixSumComputer` when the values are all zeroes --- .../editor/common/model/prefixSumComputer.ts | 8 +- .../viewModel/prefixSumComputer.test.ts | 769 ++++++++++++++---- 2 files changed, 618 insertions(+), 159 deletions(-) diff --git a/src/vs/editor/common/model/prefixSumComputer.ts b/src/vs/editor/common/model/prefixSumComputer.ts index 08376d50d8e63..10f00fa5cfdb8 100644 --- a/src/vs/editor/common/model/prefixSumComputer.ts +++ b/src/vs/editor/common/model/prefixSumComputer.ts @@ -235,6 +235,12 @@ export class ConstantTimePrefixSumComputer { public getIndexOf(sum: number): PrefixSumIndexOfResult { this._ensureValid(); const idx = this._indexBySum[sum]; + if (idx === undefined) { + // sum is out of bounds (e.g. all values are zero) + const lastIdx = Math.max(0, this._values.length - 1); + const lastPrefixSum = lastIdx > 0 ? this._prefixSum[lastIdx - 1] : 0; + return new PrefixSumIndexOfResult(lastIdx, sum - lastPrefixSum); + } const viewLinesAbove = idx > 0 ? this._prefixSum[idx - 1] : 0; return new PrefixSumIndexOfResult(idx, sum - viewLinesAbove); } @@ -271,7 +277,7 @@ export class ConstantTimePrefixSumComputer { // trim things this._prefixSum.length = this._values.length; - this._indexBySum.length = this._prefixSum[this._prefixSum.length - 1]; + this._indexBySum.length = this._values.length > 0 ? this._prefixSum[this._values.length - 1] : 0; // mark as valid this._isValid = true; diff --git a/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts b/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts index a7f934a6247a1..333b7f63cc704 100644 --- a/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts +++ b/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts @@ -6,7 +6,19 @@ import assert from 'assert'; import { toUint32 } from '../../../../base/common/uint.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { PrefixSumComputer, PrefixSumIndexOfResult } from '../../../common/model/prefixSumComputer.js'; +import { ConstantTimePrefixSumComputer, PrefixSumComputer, PrefixSumIndexOfResult } from '../../../common/model/prefixSumComputer.js'; + +interface IPrefixSumComputer { + getTotalSum(): number; + /** + * Returns sum of first `count` values: SUM(0 <= j < count, values[j]). + */ + getPrefixSum(count: number): number; + getIndexOf(sum: number): PrefixSumIndexOfResult; + setValue(index: number, value: number): void; + insertValues(insertIndex: number, insertArr: number[]): void; + removeValues(start: number, deleteCount: number): void; +} function toUint32Array(arr: number[]): Uint32Array { const len = arr.length; @@ -17,166 +29,607 @@ function toUint32Array(arr: number[]): Uint32Array { return r; } +function createBoth(values: number[]): IPrefixSumComputer[] { + const psc = new PrefixSumComputer(toUint32Array(values)); + const wrapped: IPrefixSumComputer = { + getTotalSum: () => psc.getTotalSum(), + getPrefixSum: (count: number) => count === 0 ? 0 : psc.getPrefixSum(count - 1), + getIndexOf: (sum: number) => psc.getIndexOf(sum), + setValue: (index: number, value: number) => { psc.setValue(index, value); }, + insertValues: (insertIndex: number, insertArr: number[]) => { psc.insertValues(insertIndex, toUint32Array(insertArr)); }, + removeValues: (start: number, deleteCount: number) => { psc.removeValues(start, deleteCount); }, + }; + const ct = new ConstantTimePrefixSumComputer([...values]); + const wrappedCt: IPrefixSumComputer = { + getTotalSum: () => ct.getTotalSum(), + getPrefixSum: (count: number) => ct.getPrefixSum(count), + getIndexOf: (sum: number) => ct.getIndexOf(sum), + setValue: (index: number, value: number) => { ct.setValue(index, value); }, + insertValues: (insertIndex: number, insertArr: number[]) => { ct.insertValues(insertIndex, insertArr); }, + removeValues: (start: number, deleteCount: number) => { ct.removeValues(start, deleteCount); }, + }; + return [wrapped, wrappedCt]; +} + +function forBoth(values: number[], callback: (psc: IPrefixSumComputer) => void): void { + for (const psc of createBoth(values)) { + callback(psc); + } +} + suite('Editor ViewModel - PrefixSumComputer', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('PrefixSumComputer', () => { - let indexOfResult: PrefixSumIndexOfResult; - - const psc = new PrefixSumComputer(toUint32Array([1, 1, 2, 1, 3])); - assert.strictEqual(psc.getTotalSum(), 8); - assert.strictEqual(psc.getPrefixSum(-1), 0); - assert.strictEqual(psc.getPrefixSum(0), 1); - assert.strictEqual(psc.getPrefixSum(1), 2); - assert.strictEqual(psc.getPrefixSum(2), 4); - assert.strictEqual(psc.getPrefixSum(3), 5); - assert.strictEqual(psc.getPrefixSum(4), 8); - indexOfResult = psc.getIndexOf(0); - assert.strictEqual(indexOfResult.index, 0); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(1); - assert.strictEqual(indexOfResult.index, 1); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(2); - assert.strictEqual(indexOfResult.index, 2); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(3); - assert.strictEqual(indexOfResult.index, 2); - assert.strictEqual(indexOfResult.remainder, 1); - indexOfResult = psc.getIndexOf(4); - assert.strictEqual(indexOfResult.index, 3); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(5); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(6); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 1); - indexOfResult = psc.getIndexOf(7); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 2); - indexOfResult = psc.getIndexOf(8); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 3); - - // [1, 2, 2, 1, 3] - psc.setValue(1, 2); - assert.strictEqual(psc.getTotalSum(), 9); - assert.strictEqual(psc.getPrefixSum(0), 1); - assert.strictEqual(psc.getPrefixSum(1), 3); - assert.strictEqual(psc.getPrefixSum(2), 5); - assert.strictEqual(psc.getPrefixSum(3), 6); - assert.strictEqual(psc.getPrefixSum(4), 9); - - // [1, 0, 2, 1, 3] - psc.setValue(1, 0); - assert.strictEqual(psc.getTotalSum(), 7); - assert.strictEqual(psc.getPrefixSum(0), 1); - assert.strictEqual(psc.getPrefixSum(1), 1); - assert.strictEqual(psc.getPrefixSum(2), 3); - assert.strictEqual(psc.getPrefixSum(3), 4); - assert.strictEqual(psc.getPrefixSum(4), 7); - indexOfResult = psc.getIndexOf(0); - assert.strictEqual(indexOfResult.index, 0); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(1); - assert.strictEqual(indexOfResult.index, 2); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(2); - assert.strictEqual(indexOfResult.index, 2); - assert.strictEqual(indexOfResult.remainder, 1); - indexOfResult = psc.getIndexOf(3); - assert.strictEqual(indexOfResult.index, 3); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(4); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(5); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 1); - indexOfResult = psc.getIndexOf(6); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 2); - indexOfResult = psc.getIndexOf(7); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 3); - - // [1, 0, 0, 1, 3] - psc.setValue(2, 0); - assert.strictEqual(psc.getTotalSum(), 5); - assert.strictEqual(psc.getPrefixSum(0), 1); - assert.strictEqual(psc.getPrefixSum(1), 1); - assert.strictEqual(psc.getPrefixSum(2), 1); - assert.strictEqual(psc.getPrefixSum(3), 2); - assert.strictEqual(psc.getPrefixSum(4), 5); - indexOfResult = psc.getIndexOf(0); - assert.strictEqual(indexOfResult.index, 0); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(1); - assert.strictEqual(indexOfResult.index, 3); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(2); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(3); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 1); - indexOfResult = psc.getIndexOf(4); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 2); - indexOfResult = psc.getIndexOf(5); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 3); - - // [1, 0, 0, 0, 3] - psc.setValue(3, 0); - assert.strictEqual(psc.getTotalSum(), 4); - assert.strictEqual(psc.getPrefixSum(0), 1); - assert.strictEqual(psc.getPrefixSum(1), 1); - assert.strictEqual(psc.getPrefixSum(2), 1); - assert.strictEqual(psc.getPrefixSum(3), 1); - assert.strictEqual(psc.getPrefixSum(4), 4); - indexOfResult = psc.getIndexOf(0); - assert.strictEqual(indexOfResult.index, 0); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(1); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(2); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 1); - indexOfResult = psc.getIndexOf(3); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 2); - indexOfResult = psc.getIndexOf(4); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 3); - - // [1, 1, 0, 1, 1] - psc.setValue(1, 1); - psc.setValue(3, 1); - psc.setValue(4, 1); - assert.strictEqual(psc.getTotalSum(), 4); - assert.strictEqual(psc.getPrefixSum(0), 1); - assert.strictEqual(psc.getPrefixSum(1), 2); - assert.strictEqual(psc.getPrefixSum(2), 2); - assert.strictEqual(psc.getPrefixSum(3), 3); - assert.strictEqual(psc.getPrefixSum(4), 4); - indexOfResult = psc.getIndexOf(0); - assert.strictEqual(indexOfResult.index, 0); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(1); - assert.strictEqual(indexOfResult.index, 1); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(2); - assert.strictEqual(indexOfResult.index, 3); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(3); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 0); - indexOfResult = psc.getIndexOf(4); - assert.strictEqual(indexOfResult.index, 4); - assert.strictEqual(indexOfResult.remainder, 1); + test('comprehensive setValue and getIndexOf', () => { + forBoth([1, 1, 2, 1, 3], psc => { + assert.strictEqual(psc.getTotalSum(), 8); + assert.strictEqual(psc.getPrefixSum(0), 0); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 2); + assert.strictEqual(psc.getPrefixSum(3), 4); + assert.strictEqual(psc.getPrefixSum(4), 5); + assert.strictEqual(psc.getPrefixSum(5), 8); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(2, 0)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(2, 1)); + assert.deepStrictEqual(psc.getIndexOf(4), new PrefixSumIndexOfResult(3, 0)); + assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(4, 0)); + assert.deepStrictEqual(psc.getIndexOf(6), new PrefixSumIndexOfResult(4, 1)); + assert.deepStrictEqual(psc.getIndexOf(7), new PrefixSumIndexOfResult(4, 2)); + + // [1, 2, 2, 1, 3] + psc.setValue(1, 2); + assert.strictEqual(psc.getTotalSum(), 9); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 3); + assert.strictEqual(psc.getPrefixSum(3), 5); + assert.strictEqual(psc.getPrefixSum(4), 6); + assert.strictEqual(psc.getPrefixSum(5), 9); + + // [1, 0, 2, 1, 3] + psc.setValue(1, 0); + assert.strictEqual(psc.getTotalSum(), 7); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 1); + assert.strictEqual(psc.getPrefixSum(3), 3); + assert.strictEqual(psc.getPrefixSum(4), 4); + assert.strictEqual(psc.getPrefixSum(5), 7); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(2, 0)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(2, 1)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(3, 0)); + assert.deepStrictEqual(psc.getIndexOf(4), new PrefixSumIndexOfResult(4, 0)); + assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(4, 1)); + assert.deepStrictEqual(psc.getIndexOf(6), new PrefixSumIndexOfResult(4, 2)); + + // [1, 0, 0, 1, 3] + psc.setValue(2, 0); + assert.strictEqual(psc.getTotalSum(), 5); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 1); + assert.strictEqual(psc.getPrefixSum(3), 1); + assert.strictEqual(psc.getPrefixSum(4), 2); + assert.strictEqual(psc.getPrefixSum(5), 5); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(3, 0)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(4, 0)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(4, 1)); + assert.deepStrictEqual(psc.getIndexOf(4), new PrefixSumIndexOfResult(4, 2)); + + // [1, 0, 0, 0, 3] + psc.setValue(3, 0); + assert.strictEqual(psc.getTotalSum(), 4); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 1); + assert.strictEqual(psc.getPrefixSum(3), 1); + assert.strictEqual(psc.getPrefixSum(4), 1); + assert.strictEqual(psc.getPrefixSum(5), 4); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(4, 0)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(4, 1)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(4, 2)); + + // [1, 1, 0, 1, 1] + psc.setValue(1, 1); + psc.setValue(3, 1); + psc.setValue(4, 1); + assert.strictEqual(psc.getTotalSum(), 4); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 2); + assert.strictEqual(psc.getPrefixSum(3), 2); + assert.strictEqual(psc.getPrefixSum(4), 3); + assert.strictEqual(psc.getPrefixSum(5), 4); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(3, 0)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(4, 0)); + }); + }); + + // --- getTotalSum --- + + test('getTotalSum with typical values', () => { + forBoth([1, 1, 2, 1, 3], psc => assert.strictEqual(psc.getTotalSum(), 8)); + forBoth([10], psc => assert.strictEqual(psc.getTotalSum(), 10)); + forBoth([5, 5, 5], psc => assert.strictEqual(psc.getTotalSum(), 15)); + }); + + test('getTotalSum with all zeroes', () => { + forBoth([0, 0, 0], psc => assert.strictEqual(psc.getTotalSum(), 0)); + forBoth([0], psc => assert.strictEqual(psc.getTotalSum(), 0)); + }); + + test('getTotalSum with empty array', () => { + forBoth([], psc => assert.strictEqual(psc.getTotalSum(), 0)); + }); + + test('getTotalSum with single element', () => { + forBoth([0], psc => assert.strictEqual(psc.getTotalSum(), 0)); + forBoth([1], psc => assert.strictEqual(psc.getTotalSum(), 1)); + forBoth([100], psc => assert.strictEqual(psc.getTotalSum(), 100)); + }); + + // --- getPrefixSum --- + + test('getPrefixSum with typical values', () => { + forBoth([1, 1, 2, 1, 3], psc => { + assert.strictEqual(psc.getPrefixSum(0), 0); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 2); + assert.strictEqual(psc.getPrefixSum(3), 4); + assert.strictEqual(psc.getPrefixSum(4), 5); + assert.strictEqual(psc.getPrefixSum(5), 8); + }); + }); + + test('getPrefixSum with all zeroes', () => { + forBoth([0, 0, 0], psc => { + assert.strictEqual(psc.getPrefixSum(0), 0); + assert.strictEqual(psc.getPrefixSum(1), 0); + assert.strictEqual(psc.getPrefixSum(2), 0); + assert.strictEqual(psc.getPrefixSum(3), 0); + }); + }); + + test('getPrefixSum with single element', () => { + forBoth([7], psc => { + assert.strictEqual(psc.getPrefixSum(0), 0); + assert.strictEqual(psc.getPrefixSum(1), 7); + }); + }); + + test('getPrefixSum with empty array', () => { + forBoth([], psc => { + assert.strictEqual(psc.getPrefixSum(0), 0); + }); + }); + + test('getPrefixSum with leading/trailing zeroes', () => { + forBoth([0, 0, 3, 0, 0], psc => { + assert.strictEqual(psc.getPrefixSum(0), 0); + assert.strictEqual(psc.getPrefixSum(1), 0); + assert.strictEqual(psc.getPrefixSum(2), 0); + assert.strictEqual(psc.getPrefixSum(3), 3); + assert.strictEqual(psc.getPrefixSum(4), 3); + assert.strictEqual(psc.getPrefixSum(5), 3); + }); + }); + + // --- getIndexOf --- + + test('getIndexOf with typical values', () => { + forBoth([1, 1, 2, 1, 3], psc => { + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(2, 0)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(2, 1)); + assert.deepStrictEqual(psc.getIndexOf(4), new PrefixSumIndexOfResult(3, 0)); + assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(4, 0)); + assert.deepStrictEqual(psc.getIndexOf(6), new PrefixSumIndexOfResult(4, 1)); + assert.deepStrictEqual(psc.getIndexOf(7), new PrefixSumIndexOfResult(4, 2)); + }); + }); + + test('getIndexOf with all zeroes', () => { + forBoth([0, 0, 0], psc => { + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(2, 0)); + }); + }); + + test('getIndexOf with single zero', () => { + forBoth([0], psc => { + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + }); + }); + + test('getIndexOf with single element', () => { + forBoth([5], psc => { + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(0, 1)); + assert.deepStrictEqual(psc.getIndexOf(4), new PrefixSumIndexOfResult(0, 4)); + }); + }); + + test('getIndexOf with leading zeroes', () => { + forBoth([0, 0, 3], psc => { + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(2, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(2, 1)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(2, 2)); + }); + }); + + test('getIndexOf with trailing zeroes', () => { + forBoth([3, 0, 0], psc => { + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(0, 1)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(0, 2)); + }); + }); + + test('getIndexOf with interleaved zeroes', () => { + forBoth([0, 1, 0, 2, 0], psc => { + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(3, 0)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(3, 1)); + }); + }); + + test('getIndexOf with all ones', () => { + forBoth([1, 1, 1, 1, 1], psc => { + for (let i = 0; i < 5; i++) { + assert.deepStrictEqual(psc.getIndexOf(i), new PrefixSumIndexOfResult(i, 0)); + } + }); + }); + + test('getIndexOf with large value in single element', () => { + forBoth([1000], psc => { + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(500), new PrefixSumIndexOfResult(0, 500)); + assert.deepStrictEqual(psc.getIndexOf(999), new PrefixSumIndexOfResult(0, 999)); + }); + }); + + // --- setValue --- + + test('setValue no-op when value unchanged', () => { + forBoth([1, 2, 3], psc => { + assert.strictEqual(psc.getTotalSum(), 6); + psc.setValue(1, 2); + assert.strictEqual(psc.getTotalSum(), 6); + }); + }); + + test('setValue increase', () => { + forBoth([1, 2, 3], psc => { + psc.setValue(1, 5); + assert.strictEqual(psc.getTotalSum(), 9); + assert.strictEqual(psc.getPrefixSum(2), 6); + assert.strictEqual(psc.getPrefixSum(3), 9); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(1, 4)); + assert.deepStrictEqual(psc.getIndexOf(6), new PrefixSumIndexOfResult(2, 0)); + }); + }); + + test('setValue decrease', () => { + forBoth([1, 5, 3], psc => { + psc.setValue(1, 2); + assert.strictEqual(psc.getTotalSum(), 6); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(1, 1)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(2, 0)); + }); + }); + + test('setValue to zero', () => { + forBoth([1, 2, 3], psc => { + psc.setValue(1, 0); + assert.strictEqual(psc.getTotalSum(), 4); + assert.strictEqual(psc.getPrefixSum(2), 1); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(2, 0)); + }); + }); + + test('setValue from zero', () => { + forBoth([0, 0, 0], psc => { + psc.setValue(1, 3); + assert.strictEqual(psc.getTotalSum(), 3); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(1, 2)); + }); + }); + + test('setValue on first element', () => { + forBoth([1, 2, 3], psc => { + psc.setValue(0, 10); + assert.strictEqual(psc.getTotalSum(), 15); + assert.strictEqual(psc.getPrefixSum(1), 10); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(9), new PrefixSumIndexOfResult(0, 9)); + assert.deepStrictEqual(psc.getIndexOf(10), new PrefixSumIndexOfResult(1, 0)); + }); + }); + + test('setValue on last element', () => { + forBoth([1, 2, 3], psc => { + psc.setValue(2, 10); + assert.strictEqual(psc.getTotalSum(), 13); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(2, 0)); + assert.deepStrictEqual(psc.getIndexOf(12), new PrefixSumIndexOfResult(2, 9)); + }); + }); + + test('set all values to zero then restore', () => { + forBoth([1, 2, 3], psc => { + psc.setValue(0, 0); + psc.setValue(1, 0); + psc.setValue(2, 0); + assert.strictEqual(psc.getTotalSum(), 0); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(2, 0)); + + psc.setValue(0, 4); + assert.strictEqual(psc.getTotalSum(), 4); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(0, 3)); + }); + }); + + test('setValue multiple times on same index', () => { + forBoth([1, 1, 1], psc => { + psc.setValue(1, 5); + psc.setValue(1, 2); + psc.setValue(1, 10); + assert.strictEqual(psc.getTotalSum(), 12); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(10), new PrefixSumIndexOfResult(1, 9)); + assert.deepStrictEqual(psc.getIndexOf(11), new PrefixSumIndexOfResult(2, 0)); + }); + }); + + // --- insertValues --- + + test('insertValues at beginning', () => { + forBoth([3, 4], psc => { + psc.insertValues(0, [1, 2]); + assert.strictEqual(psc.getTotalSum(), 10); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 3); + assert.strictEqual(psc.getPrefixSum(3), 6); + assert.strictEqual(psc.getPrefixSum(4), 10); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(2, 0)); + }); + }); + + test('insertValues at end', () => { + forBoth([1, 2], psc => { + psc.insertValues(2, [3, 4]); + assert.strictEqual(psc.getTotalSum(), 10); + assert.strictEqual(psc.getPrefixSum(3), 6); + assert.strictEqual(psc.getPrefixSum(4), 10); + }); + }); + + test('insertValues in the middle', () => { + forBoth([1, 4], psc => { + psc.insertValues(1, [2, 3]); + assert.strictEqual(psc.getTotalSum(), 10); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 3); + assert.strictEqual(psc.getPrefixSum(3), 6); + assert.strictEqual(psc.getPrefixSum(4), 10); + }); + }); + + test('insertValues with zeroes', () => { + forBoth([1, 2], psc => { + psc.insertValues(1, [0, 0]); + assert.strictEqual(psc.getTotalSum(), 3); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 1); + assert.strictEqual(psc.getPrefixSum(3), 1); + assert.strictEqual(psc.getPrefixSum(4), 3); + }); + }); + + test('insertValues into all-zeroes', () => { + forBoth([0, 0, 0], psc => { + psc.insertValues(1, [2, 3]); + assert.strictEqual(psc.getTotalSum(), 5); + assert.strictEqual(psc.getPrefixSum(1), 0); + assert.strictEqual(psc.getPrefixSum(2), 2); + assert.strictEqual(psc.getPrefixSum(3), 5); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(2, 0)); + assert.deepStrictEqual(psc.getIndexOf(4), new PrefixSumIndexOfResult(2, 2)); + }); + }); + + test('insertValues into empty computer', () => { + forBoth([], psc => { + psc.insertValues(0, [5, 3]); + assert.strictEqual(psc.getTotalSum(), 8); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(4), new PrefixSumIndexOfResult(0, 4)); + assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(1, 0)); + }); + }); + + // --- removeValues --- + + test('removeValues from beginning', () => { + forBoth([1, 2, 3, 4], psc => { + psc.removeValues(0, 2); + assert.strictEqual(psc.getTotalSum(), 7); + assert.strictEqual(psc.getPrefixSum(1), 3); + assert.strictEqual(psc.getPrefixSum(2), 7); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(1, 0)); + }); + }); + + test('removeValues from end', () => { + forBoth([1, 2, 3, 4], psc => { + psc.removeValues(2, 2); + assert.strictEqual(psc.getTotalSum(), 3); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 3); + }); + }); + + test('removeValues from the middle', () => { + forBoth([1, 2, 3, 4], psc => { + psc.removeValues(1, 2); + assert.strictEqual(psc.getTotalSum(), 5); + assert.strictEqual(psc.getPrefixSum(1), 1); + assert.strictEqual(psc.getPrefixSum(2), 5); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(4), new PrefixSumIndexOfResult(1, 3)); + }); + }); + + test('removeValues all', () => { + forBoth([1, 2, 3], psc => { + psc.removeValues(0, 3); + assert.strictEqual(psc.getTotalSum(), 0); + }); + }); + + test('removeValues single element', () => { + forBoth([5, 10, 15], psc => { + psc.removeValues(1, 1); + assert.strictEqual(psc.getTotalSum(), 20); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(19), new PrefixSumIndexOfResult(1, 14)); + }); + }); + + test('removeValues zero-valued elements', () => { + forBoth([0, 0, 5, 0, 0], psc => { + psc.removeValues(0, 2); + assert.strictEqual(psc.getTotalSum(), 5); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(4), new PrefixSumIndexOfResult(0, 4)); + }); + }); + + // --- combined operations --- + + test('insert then remove', () => { + forBoth([1, 2, 3], psc => { + psc.insertValues(1, [10, 20]); + assert.strictEqual(psc.getTotalSum(), 36); + psc.removeValues(1, 2); + assert.strictEqual(psc.getTotalSum(), 6); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(3), new PrefixSumIndexOfResult(2, 0)); + }); + }); + + test('remove then insert at same position', () => { + forBoth([1, 2, 3], psc => { + psc.removeValues(1, 1); + psc.insertValues(1, [5]); + assert.strictEqual(psc.getTotalSum(), 9); + assert.deepStrictEqual(psc.getIndexOf(1), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(1, 4)); + assert.deepStrictEqual(psc.getIndexOf(6), new PrefixSumIndexOfResult(2, 0)); + }); + }); + + test('setValue then insert then remove', () => { + forBoth([1, 1, 1], psc => { + psc.setValue(0, 5); + psc.insertValues(1, [10]); + psc.removeValues(3, 1); + // [5, 10, 1] + assert.strictEqual(psc.getTotalSum(), 16); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(4), new PrefixSumIndexOfResult(0, 4)); + assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(14), new PrefixSumIndexOfResult(1, 9)); + assert.deepStrictEqual(psc.getIndexOf(15), new PrefixSumIndexOfResult(2, 0)); + }); + }); + + test('multiple queries between mutations are consistent', () => { + forBoth([2, 3, 5], psc => { + assert.strictEqual(psc.getTotalSum(), 10); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + + psc.setValue(1, 0); + assert.strictEqual(psc.getTotalSum(), 7); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(2, 0)); + + psc.setValue(1, 3); + assert.strictEqual(psc.getTotalSum(), 10); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(1, 0)); + }); + }); + + // --- edge cases --- + + test('large values', () => { + forBoth([100, 200, 300], psc => { + assert.strictEqual(psc.getTotalSum(), 600); + assert.strictEqual(psc.getPrefixSum(1), 100); + assert.strictEqual(psc.getPrefixSum(2), 300); + assert.strictEqual(psc.getPrefixSum(3), 600); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(0, 0)); + assert.deepStrictEqual(psc.getIndexOf(99), new PrefixSumIndexOfResult(0, 99)); + assert.deepStrictEqual(psc.getIndexOf(100), new PrefixSumIndexOfResult(1, 0)); + assert.deepStrictEqual(psc.getIndexOf(299), new PrefixSumIndexOfResult(1, 199)); + assert.deepStrictEqual(psc.getIndexOf(300), new PrefixSumIndexOfResult(2, 0)); + assert.deepStrictEqual(psc.getIndexOf(599), new PrefixSumIndexOfResult(2, 299)); + }); + }); + + test('many elements', () => { + forBoth(new Array(100).fill(1), psc => { + assert.strictEqual(psc.getTotalSum(), 100); + assert.strictEqual(psc.getPrefixSum(50), 50); + + for (let i = 0; i < 100; i++) { + assert.deepStrictEqual(psc.getIndexOf(i), new PrefixSumIndexOfResult(i, 0)); + } + }); + }); + + test('many elements all zeroes', () => { + forBoth(new Array(100).fill(0), psc => { + assert.strictEqual(psc.getTotalSum(), 0); + for (let i = 0; i <= 100; i++) { + assert.strictEqual(psc.getPrefixSum(i), 0); + } + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(99, 0)); + }); + }); + + test('setValue between queries re-validates correctly', () => { + forBoth([1, 1, 1, 1, 1], psc => { + assert.strictEqual(psc.getTotalSum(), 5); + + psc.setValue(2, 10); + assert.strictEqual(psc.getTotalSum(), 14); + assert.strictEqual(psc.getPrefixSum(3), 12); + assert.deepStrictEqual(psc.getIndexOf(2), new PrefixSumIndexOfResult(2, 0)); + assert.deepStrictEqual(psc.getIndexOf(11), new PrefixSumIndexOfResult(2, 9)); + assert.deepStrictEqual(psc.getIndexOf(12), new PrefixSumIndexOfResult(3, 0)); + assert.deepStrictEqual(psc.getIndexOf(13), new PrefixSumIndexOfResult(4, 0)); + + psc.setValue(0, 0); + assert.strictEqual(psc.getTotalSum(), 13); + assert.deepStrictEqual(psc.getIndexOf(0), new PrefixSumIndexOfResult(1, 0)); + }); }); }); From f21433b98c46317c93fdf6ba8cfa1069c354a03c Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Sat, 21 Feb 2026 11:40:11 +0100 Subject: [PATCH 02/15] Update src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts b/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts index 333b7f63cc704..181a3f67edeed 100644 --- a/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts +++ b/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts @@ -229,6 +229,7 @@ suite('Editor ViewModel - PrefixSumComputer', () => { assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(4, 0)); assert.deepStrictEqual(psc.getIndexOf(6), new PrefixSumIndexOfResult(4, 1)); assert.deepStrictEqual(psc.getIndexOf(7), new PrefixSumIndexOfResult(4, 2)); + assert.deepStrictEqual(psc.getIndexOf(8), new PrefixSumIndexOfResult(4, 3)); }); }); From 1fd39ecd9e9585113ace96f04c37afef2e44c124 Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Sat, 21 Feb 2026 11:40:24 +0100 Subject: [PATCH 03/15] Update src/vs/editor/common/model/prefixSumComputer.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/editor/common/model/prefixSumComputer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/editor/common/model/prefixSumComputer.ts b/src/vs/editor/common/model/prefixSumComputer.ts index 10f00fa5cfdb8..04cf4ccc1e493 100644 --- a/src/vs/editor/common/model/prefixSumComputer.ts +++ b/src/vs/editor/common/model/prefixSumComputer.ts @@ -236,7 +236,7 @@ export class ConstantTimePrefixSumComputer { this._ensureValid(); const idx = this._indexBySum[sum]; if (idx === undefined) { - // sum is out of bounds (e.g. all values are zero) + // sum does not have a direct entry in _indexBySum (e.g. sum >= getTotalSum() or the array is empty / all values are zero) const lastIdx = Math.max(0, this._values.length - 1); const lastPrefixSum = lastIdx > 0 ? this._prefixSum[lastIdx - 1] : 0; return new PrefixSumIndexOfResult(lastIdx, sum - lastPrefixSum); From 86dc9c4e384719c709240bef95b86d1ffa83677e Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Feb 2026 10:01:59 +0100 Subject: [PATCH 04/15] fix: ensure at least one line remains visible after edits with hidden areas When an edit (delete/undo) removes or merges the only visible line(s) into hidden lines, the view model could end up with zero visible lines, causing downstream crashes (e.g. 'Not supported' from HiddenModelLineProjection). The safety check in acceptVersionId only caught the case where exactly 1 hidden line remained. Generalize it to detect when getViewLineCount() === 0 and minimally restore visibility by making just the first line visible, preserving the caller's intended hidden areas as much as possible. --- .../editor/common/viewModel/viewModelLines.ts | 9 +- .../browser/viewModel/viewModelImpl.test.ts | 161 ++++++++++++++++++ 2 files changed, 167 insertions(+), 3 deletions(-) diff --git a/src/vs/editor/common/viewModel/viewModelLines.ts b/src/vs/editor/common/viewModel/viewModelLines.ts index 4a27a5a077ba0..91082393ab4c7 100644 --- a/src/vs/editor/common/viewModel/viewModelLines.ts +++ b/src/vs/editor/common/viewModel/viewModelLines.ts @@ -423,9 +423,12 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { public acceptVersionId(versionId: number): void { this._validModelVersionId = versionId; - if (this.modelLineProjections.length === 1 && !this.modelLineProjections[0].isVisible()) { - // At least one line must be visible => reset hidden areas - this.setHiddenAreas([]); + if (this.getViewLineCount() === 0 && this.modelLineProjections.length > 0) { + // At least one line must be visible. + // An edit caused all visible lines to be removed/merged into hidden lines. + // Make just the first line visible to minimally disrupt the intended hidden areas. + this.modelLineProjections[0] = this.modelLineProjections[0].setVisible(true); + this.projectedModelLineLineCounts.setValue(0, this.modelLineProjections[0].getViewLineCount()); } } diff --git a/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts b/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts index 2b9655e91504d..7766858f6534a 100644 --- a/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts +++ b/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts @@ -426,4 +426,165 @@ suite('ViewModel', () => { } ); }); + + suite('hidden areas must always leave at least one visible line', () => { + + test('replacing the only visible line content does not make it hidden', () => { + const text = [ + 'line1', + 'line2', + 'line3', + ]; + testViewModel(text, {}, (viewModel, model) => { + // Hide lines 1 and 3, leaving only line 2 visible + viewModel.setHiddenAreas([ + new Range(1, 1, 1, 1), + new Range(3, 1, 3, 1), + ]); + assert.strictEqual(viewModel.getLineCount(), 1); + + // Replace line 2 content entirely + model.applyEdits([{ + range: new Range(2, 1, 2, 6), + text: 'new content' + }]); + + assert.ok(viewModel.getLineCount() >= 1, `expected at least 1 view line but got ${viewModel.getLineCount()}`); + }); + }); + + test('deleting the only visible line when it is the last line', () => { + const text = [ + 'line1', + 'line2', + 'line3', + ]; + testViewModel(text, {}, (viewModel, model) => { + // Hide lines 1-2, leaving only line 3 visible + viewModel.setHiddenAreas([new Range(1, 1, 2, 1)]); + assert.strictEqual(viewModel.getLineCount(), 1); + + // Delete line 3 by merging it into line 2 + model.applyEdits([{ + range: new Range(2, 6, 3, 6), + text: null + }]); + + assert.ok(viewModel.getLineCount() >= 1, `expected at least 1 view line but got ${viewModel.getLineCount()}`); + }); + }); + + test('deleting the only visible line when it is in the middle', () => { + const text = [ + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + ]; + testViewModel(text, {}, (viewModel, model) => { + // Hide lines 1-2 and 4-5, leaving only line 3 visible + viewModel.setHiddenAreas([ + new Range(1, 1, 2, 1), + new Range(4, 1, 5, 1), + ]); + assert.strictEqual(viewModel.getLineCount(), 1); + + // Delete line 3 by merging adjacent lines + model.applyEdits([{ + range: new Range(2, 6, 4, 1), + text: null + }]); + + assert.ok(viewModel.getLineCount() >= 1, `expected at least 1 view line but got ${viewModel.getLineCount()}`); + }); + }); + + test('undo that removes the only visible line', () => { + const text = [ + 'line1', + ]; + testViewModel(text, {}, (viewModel, model) => { + assert.strictEqual(viewModel.getLineCount(), 1); + + // Insert lines to create content + model.pushEditOperations([], [{ + range: new Range(1, 6, 1, 6), + text: '\nline2\nline3\nline4\nline5' + }], () => ([])); + + assert.strictEqual(viewModel.getLineCount(), 5); + + // Hide lines 1-2 and 4-5, leaving only line 3 visible + viewModel.setHiddenAreas([ + new Range(1, 1, 2, 1), + new Range(4, 1, 5, 1), + ]); + assert.strictEqual(viewModel.getLineCount(), 1); + + // Undo collapses back to 1 line, but hidden area decorations may grow + model.undo(); + + assert.ok(viewModel.getLineCount() >= 1, `expected at least 1 view line but got ${viewModel.getLineCount()}`); + }); + }); + + test('deleting the only visible line between two hidden areas leaves all lines hidden', () => { + const text = [ + 'line1', + 'line2', + 'line3', + 'line4', + 'line5', + 'line6', + 'line7', + 'line8', + ]; + testViewModel(text, {}, (viewModel, model) => { + assert.strictEqual(viewModel.getLineCount(), 8); + + // Hide lines 1-5 and 7-8, leaving only line 6 visible + viewModel.setHiddenAreas([ + new Range(1, 1, 5, 1), + new Range(7, 1, 8, 1), + ]); + assert.strictEqual(viewModel.getLineCount(), 1); + + // Delete lines 6, 7, 8 — the only visible line plus some hidden ones + model.applyEdits([{ + range: new Range(6, 1, 8, 5), + text: null + }]); + + // The view model must still have at least one visible line + assert.ok(viewModel.getLineCount() >= 1, `expected at least 1 view line but got ${viewModel.getLineCount()}`); + }); + }); + + test('multiple visible lines deleted leaving only hidden lines', () => { + const text = [ + 'hidden1', + 'hidden2', + 'visible1', + 'visible2', + 'hidden3', + 'hidden4', + ]; + testViewModel(text, {}, (viewModel, model) => { + viewModel.setHiddenAreas([ + new Range(1, 1, 2, 1), + new Range(5, 1, 6, 1), + ]); + assert.strictEqual(viewModel.getLineCount(), 2); + + // Delete visible lines 3 and 4 + model.applyEdits([{ + range: new Range(2, 8, 5, 1), + text: null + }]); + + assert.ok(viewModel.getLineCount() >= 1, `expected at least 1 view line but got ${viewModel.getLineCount()}`); + }); + }); + }); }); From 2af23ffd2955028fc17e8c4e1acd1ecc7c16510f Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Feb 2026 10:18:45 +0100 Subject: [PATCH 05/15] Revert setViewport early return (aee9b1b3) since the root cause (0 visible lines) is now fixed at the source --- src/vs/editor/common/viewModel/viewModelImpl.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index f3f33283bfc11..db795ac7371d1 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -781,10 +781,6 @@ export class ViewModel extends Disposable implements IViewModel { * Gives a hint that a lot of requests are about to come in for these line numbers. */ public setViewport(startLineNumber: number, endLineNumber: number, centeredLineNumber: number): void { - if (this._lines.getViewLineCount() === 0) { - // No visible lines to set viewport on - return; - } this._viewportStart.update(this, startLineNumber); } From cb1391c68fcd55b8c9e8933a3a8a3bad742fd103 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Feb 2026 11:24:04 +0100 Subject: [PATCH 06/15] Add unit test for overlapping hidden ranges --- .../browser/viewModel/viewModelImpl.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts b/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts index 7766858f6534a..650e5c255548b 100644 --- a/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts +++ b/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts @@ -586,5 +586,35 @@ suite('ViewModel', () => { assert.ok(viewModel.getLineCount() >= 1, `expected at least 1 view line but got ${viewModel.getLineCount()}`); }); }); + + test('hidden areas from multiple sources that overlap produce valid merged result', () => { + const text: string[] = []; + for (let i = 1; i <= 10; i++) { + text.push(`line${i}`); + } + testViewModel(text, {}, (viewModel, model) => { + // Source A hides a large range [1-8]. + // Source B hides small ranges [2-3] and [5-6] that are subsumed by A. + // mergeLineRangeArray has a bug where it advances both pointers after + // merging [1-8]+[2-3]=[1-8], leaving [5-6] and [8,9] as separate entries + // that overlap with or are subsumed by [1-8]. + // normalizeLineRanges in setHiddenAreas cleans this up, so the result + // should still be correct: lines 1-8 hidden, lines 9-10 visible. + viewModel.setHiddenAreas([new Range(1, 1, 8, 1)], 'sourceA'); + viewModel.setHiddenAreas([new Range(2, 1, 3, 1), new Range(5, 1, 6, 1), new Range(8, 1, 9, 1)], 'sourceB'); + + // Lines 1-9 should be hidden (merged from [1-8] and [8-9]), line 10 visible + assert.strictEqual(viewModel.getLineCount(), 1, 'only line 10 should be visible'); + + // The hidden areas returned should be non-overlapping and sorted + const hiddenAreas = viewModel.getHiddenAreas(); + for (let i = 1; i < hiddenAreas.length; i++) { + assert.ok( + hiddenAreas[i].startLineNumber > hiddenAreas[i - 1].endLineNumber, + `hidden areas should not overlap: [${hiddenAreas[i - 1].startLineNumber}-${hiddenAreas[i - 1].endLineNumber}] and [${hiddenAreas[i].startLineNumber}-${hiddenAreas[i].endLineNumber}]` + ); + } + }); + }); }); }); From 3c5c4ba6866ec941db6a9ce24a24a50e0aed3296 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Feb 2026 11:32:48 +0100 Subject: [PATCH 07/15] fix: safety check in _constructLines for 0 visible lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When hidden area decorations drift via AlwaysGrowsWhenTypingAtEdges stickiness, _constructLines(resetHiddenAreas=false) — triggered by tab size or wrapping changes — can rebuild projections with all lines hidden. Extract _ensureAtLeastOneVisibleLine() shared by both _constructLines and acceptVersionId. --- .../editor/common/viewModel/viewModelLines.ts | 9 ++++-- .../browser/viewModel/viewModelImpl.test.ts | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/vs/editor/common/viewModel/viewModelLines.ts b/src/vs/editor/common/viewModel/viewModelLines.ts index 91082393ab4c7..dc14410a12d72 100644 --- a/src/vs/editor/common/viewModel/viewModelLines.ts +++ b/src/vs/editor/common/viewModel/viewModelLines.ts @@ -165,6 +165,8 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { this._validModelVersionId = this.model.getVersionId(); this.projectedModelLineLineCounts = new ConstantTimePrefixSumComputer(values); + + this._ensureAtLeastOneVisibleLine(); } public getHiddenAreas(): Range[] { @@ -423,10 +425,11 @@ export class ViewModelLinesFromProjectedModel implements IViewModelLines { public acceptVersionId(versionId: number): void { this._validModelVersionId = versionId; + this._ensureAtLeastOneVisibleLine(); + } + + private _ensureAtLeastOneVisibleLine(): void { if (this.getViewLineCount() === 0 && this.modelLineProjections.length > 0) { - // At least one line must be visible. - // An edit caused all visible lines to be removed/merged into hidden lines. - // Make just the first line visible to minimally disrupt the intended hidden areas. this.modelLineProjections[0] = this.modelLineProjections[0].setVisible(true); this.projectedModelLineLineCounts.setValue(0, this.modelLineProjections[0].getViewLineCount()); } diff --git a/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts b/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts index 650e5c255548b..06ee277c8f68b 100644 --- a/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts +++ b/src/vs/editor/test/browser/viewModel/viewModelImpl.test.ts @@ -616,5 +616,37 @@ suite('ViewModel', () => { } }); }); + + test('tab size change with drifted hidden area decorations must not leave 0 visible lines', () => { + const text = [ + 'line1', + 'line2', + 'line3', + ]; + testViewModel(text, {}, (viewModel, model) => { + // Hide lines 1-2, leaving only line 3 visible. + viewModel.setHiddenAreas([new Range(1, 1, 2, 1)]); + assert.strictEqual(viewModel.getLineCount(), 1); + + // Insert at (2,1) — the end edge of the hidden area decoration. + // AlwaysGrowsWhenTypingAtEdges causes the decoration to grow from + // [1,1 → 2,1] to [1,1 → 3,1], covering what was the visible line 3. + // After this insert, the file has 4 lines, decoration covers [1-3], line 4 visible. + model.applyEdits([{ range: new Range(2, 1, 2, 1), text: 'x\n' }]); + // Insert again to push decoration further + model.applyEdits([{ range: new Range(3, 1, 3, 1), text: 'y\n' }]); + // Now file has 5 lines, decoration covers [1-4], line 5 visible. + + // Delete lines 4-5 to collapse back, making decoration cover everything + model.applyEdits([{ range: new Range(4, 1, 5, 6), text: '' }]); + // Now file has 4 lines. acceptVersionId ensures viewLines >= 1. + + // Tab size change: triggers _constructLines(resetHiddenAreas=false) + // which re-reads the decoration ranges (which may cover all lines). + model.updateOptions({ tabSize: 8 }); + + assert.ok(viewModel.getLineCount() >= 1, `expected at least 1 view line but got ${viewModel.getLineCount()}`); + }); + }); }); }); From fd182ab8017d90bba9e3b4a2b6ad06f5f64603af Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Feb 2026 11:36:27 +0100 Subject: [PATCH 08/15] Protect event delivery --- src/vs/editor/common/model/textModel.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index bb3a86c98575b..638fafb925af2 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -1680,10 +1680,18 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati private _onDidChangeContentOrInjectedText(e: InternalModelContentChangeEvent | ModelInjectedTextChangedEvent): void { for (const viewModel of this._viewModels) { - viewModel.onDidChangeContentOrInjectedText(e); + try { + viewModel.onDidChangeContentOrInjectedText(e); + } catch (error) { + onUnexpectedError(error); + } } for (const viewModel of this._viewModels) { - viewModel.emitContentChangeEvent(e); + try { + viewModel.emitContentChangeEvent(e); + } catch (error) { + onUnexpectedError(error); + } } } From bc0e092d5343f16c0b187d3e37c9d4bba30f448a Mon Sep 17 00:00:00 2001 From: Alexandru Dima Date: Sat, 21 Feb 2026 13:10:56 +0100 Subject: [PATCH 09/15] Update src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts b/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts index 181a3f67edeed..71b52fcf986ad 100644 --- a/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts +++ b/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts @@ -78,6 +78,7 @@ suite('Editor ViewModel - PrefixSumComputer', () => { assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(4, 0)); assert.deepStrictEqual(psc.getIndexOf(6), new PrefixSumIndexOfResult(4, 1)); assert.deepStrictEqual(psc.getIndexOf(7), new PrefixSumIndexOfResult(4, 2)); + assert.deepStrictEqual(psc.getIndexOf(8), new PrefixSumIndexOfResult(5, 0)); // [1, 2, 2, 1, 3] psc.setValue(1, 2); From f1d5b97061b98f5525af04ed344e7ad818f868d2 Mon Sep 17 00:00:00 2001 From: Alex Dima Date: Sat, 21 Feb 2026 13:47:41 +0100 Subject: [PATCH 10/15] Fix unit test --- src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts b/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts index 71b52fcf986ad..bd7f3e99e5275 100644 --- a/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts +++ b/src/vs/editor/test/common/viewModel/prefixSumComputer.test.ts @@ -78,7 +78,7 @@ suite('Editor ViewModel - PrefixSumComputer', () => { assert.deepStrictEqual(psc.getIndexOf(5), new PrefixSumIndexOfResult(4, 0)); assert.deepStrictEqual(psc.getIndexOf(6), new PrefixSumIndexOfResult(4, 1)); assert.deepStrictEqual(psc.getIndexOf(7), new PrefixSumIndexOfResult(4, 2)); - assert.deepStrictEqual(psc.getIndexOf(8), new PrefixSumIndexOfResult(5, 0)); + assert.deepStrictEqual(psc.getIndexOf(8), new PrefixSumIndexOfResult(4, 3)); // [1, 2, 2, 1, 3] psc.setValue(1, 2); From 66e02a33c873a606276a9886d267d5fb238d1e81 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Sat, 21 Feb 2026 21:20:42 +0100 Subject: [PATCH 11/15] GitService - fix issue with opening a repository (#296752) --- .../api/common/extHostGitExtensionService.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/api/common/extHostGitExtensionService.ts b/src/vs/workbench/api/common/extHostGitExtensionService.ts index 8029798f00f0d..2c782de4cac43 100644 --- a/src/vs/workbench/api/common/extHostGitExtensionService.ts +++ b/src/vs/workbench/api/common/extHostGitExtensionService.ts @@ -6,7 +6,6 @@ import type * as vscode from 'vscode'; import { Event } from '../../../base/common/event.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { observableFromEvent, waitForState } from '../../../base/common/observable.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; @@ -50,6 +49,7 @@ interface Repository { readonly rootUri: vscode.Uri; readonly state: RepositoryState; + status(): Promise; getRefs(query: GitRefQuery, token?: vscode.CancellationToken): Promise; } @@ -154,12 +154,8 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi }; } - // Ensure that the repository state is initialized - const repositoryStateObs = observableFromEvent(this, - repository.state.onDidChange, () => repository.state); - await waitForState(repositoryStateObs, state => !!state.HEAD); - - const repositoryState = repositoryStateObs.get(); + // Update repository state + await repository.status(); // Store the repository and its handle in the maps const handle = ExtHostGitExtensionService._handlePool++; @@ -176,8 +172,8 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi handle, rootUri: repository.rootUri, state: { - HEAD: repositoryState.HEAD - ? toGitBranchDto(repositoryState.HEAD) + HEAD: repository.state.HEAD + ? toGitBranchDto(repository.state.HEAD) : undefined } }; From 483dbd1fdd0fb856c8310943d1a726415961c3ef Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Sat, 21 Feb 2026 21:40:40 +0100 Subject: [PATCH 12/15] sessions - stop blocking the context menu on sessions (#296755) --- .../contrib/chat/browser/agentSessions/agentSessionsControl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index b1e5fdc159459..384baff52d4e3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -301,7 +301,7 @@ export class AgentSessionsControl extends Disposable implements IAgentSessionsCo } private async showAgentSessionContextMenu(session: IAgentSession, anchor: HTMLElement | IMouseEvent): Promise { - await this.chatSessionsService.activateChatSessionItemProvider(session.providerType); + this.chatSessionsService.activateChatSessionItemProvider(session.providerType); const contextOverlay: Array<[string, boolean | string]> = []; contextOverlay.push([ChatContextKeys.isArchivedAgentSession.key, session.isArchived()]); From 257a0cf20baf1da53c06cba6b9ebe0c044f71216 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Sat, 21 Feb 2026 22:00:20 +0100 Subject: [PATCH 13/15] GitService - more perf tweaks (#296759) GitService - more tweaks --- .../api/common/extHostGitExtensionService.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/api/common/extHostGitExtensionService.ts b/src/vs/workbench/api/common/extHostGitExtensionService.ts index 2c782de4cac43..61a64e83bf375 100644 --- a/src/vs/workbench/api/common/extHostGitExtensionService.ts +++ b/src/vs/workbench/api/common/extHostGitExtensionService.ts @@ -154,8 +154,15 @@ export class ExtHostGitExtensionService extends Disposable implements IExtHostGi }; } - // Update repository state - await repository.status(); + let repositoryState = repository.state; + if (repositoryState.HEAD === undefined) { + // Opening the repository does not wait for the repository state to be + // initialized so we need to wait for the first change event to ensure + // that the repository state is fully loaded before we return it to the + // main thread. + await Event.toPromise(repositoryState.onDidChange, this._disposables); + repositoryState = repository.state; + } // Store the repository and its handle in the maps const handle = ExtHostGitExtensionService._handlePool++; From aa825dfa93a11b416673b7728cfe54fd23ca7e30 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Sat, 21 Feb 2026 22:08:50 +0100 Subject: [PATCH 14/15] add logging (#296756) * add logging * feedback --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 431e7e953d200..ccb09bd0b06f1 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -53,6 +53,7 @@ import { IGitService } from '../../../../workbench/contrib/git/common/gitService import { IsolationModePicker, SessionTargetPicker } from './sessionTargetPicker.js'; import { BranchPicker } from './branchPicker.js'; import { INewSession } from './newSession.js'; +import { getErrorMessage } from '../../../../base/common/errors.js'; // #region --- Chat Welcome Widget --- @@ -257,7 +258,8 @@ class NewChatWidget extends Disposable { this.gitService.openRepository(folderUri).then(repository => { this._isolationModePicker.setRepository(repository); this._branchPicker.setRepository(repository); - }).catch(() => { + }).catch(e => { + this.logService.warn(`Failed to open repository at ${folderUri.toString()}`, getErrorMessage(e)); this._isolationModePicker.setRepository(undefined); this._branchPicker.setRepository(undefined); }); From beb0fcb0c5f07a15ca107ff36ab5df856d9f9051 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Sat, 21 Feb 2026 22:49:14 +0100 Subject: [PATCH 15/15] sessions - allow to open editors in new window (#296701) --- .../browser/parts/editor/editorCommands.ts | 37 +++++++++++++++++++ .../browser/parts/editor/modalEditorPart.ts | 3 +- .../test/browser/modalEditorGroup.test.ts | 30 +++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 961340c6548c7..cedb8c9537371 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -107,6 +107,7 @@ export const NEW_EMPTY_EDITOR_WINDOW_COMMAND_ID = 'workbench.action.newEmptyEdit export const CLOSE_MODAL_EDITOR_COMMAND_ID = 'workbench.action.closeModalEditor'; export const MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID = 'workbench.action.moveModalEditorToMain'; +export const MOVE_MODAL_EDITOR_TO_WINDOW_COMMAND_ID = 'workbench.action.moveModalEditorToWindow'; export const TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID = 'workbench.action.toggleModalEditorMaximized'; export const NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID = 'workbench.action.navigateModalEditorPrevious'; export const NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID = 'workbench.action.navigateModalEditorNext'; @@ -1439,6 +1440,42 @@ function registerModalEditorCommands(): void { } }); + registerAction2(class extends Action2 { + constructor() { + super({ + id: MOVE_MODAL_EDITOR_TO_WINDOW_COMMAND_ID, + title: localize2('moveModalEditorToWindow', 'Open Modal Editor in New Window'), + category: Categories.View, + f1: true, + icon: Codicon.emptyWindow, + precondition: EditorPartModalContext, + menu: { + id: MenuId.ModalEditorTitle, + group: 'navigation', + order: 0, + when: IsSessionsWindowContext + } + }); + } + async run(accessor: ServicesAccessor): Promise { + const editorGroupsService = accessor.get(IEditorGroupsService); + + for (const part of editorGroupsService.parts) { + if (isModalEditorPart(part)) { + const auxiliaryEditorPart = await editorGroupsService.createAuxiliaryEditorPart(); + + for (const group of part.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE)) { + group.moveEditors(group.editors.map(editor => ({ editor, options: { preserveFocus: true } })), auxiliaryEditorPart.activeGroup); + } + + auxiliaryEditorPart.activeGroup.focus(); + part.close(); + break; + } + } + } + }); + registerAction2(class extends Action2 { constructor() { super({ diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 1f6e93ba311e1..d75fcd17e5a17 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -32,7 +32,7 @@ import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser import { mainWindow } from '../../../../base/browser/window.js'; import { localize } from '../../../../nls.js'; import { Codicon } from '../../../../base/common/codicons.js'; -import { CLOSE_MODAL_EDITOR_COMMAND_ID, MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID, NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID, TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID } from './editorCommands.js'; +import { CLOSE_MODAL_EDITOR_COMMAND_ID, MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, MOVE_MODAL_EDITOR_TO_WINDOW_COMMAND_ID, NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID, NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID, TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID } from './editorCommands.js'; import { IModalEditorNavigation, IModalEditorPartOptions } from '../../../../platform/editor/common/editor.js'; const defaultModalEditorAllowableCommands = new Set([ @@ -44,6 +44,7 @@ const defaultModalEditorAllowableCommands = new Set([ 'workbench.action.files.saveAll', CLOSE_MODAL_EDITOR_COMMAND_ID, MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, + MOVE_MODAL_EDITOR_TO_WINDOW_COMMAND_ID, TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID, NAVIGATE_MODAL_EDITOR_PREVIOUS_COMMAND_ID, NAVIGATE_MODAL_EDITOR_NEXT_COMMAND_ID, diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index fe6a33802b2fe..865cb028552ff 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -583,5 +583,35 @@ suite('Modal Editor Group', () => { }); }); + test('modal editor part editors can be moved to another group', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create modal and open editors + const modalPart = await parts.createModalEditorPart(); + const input1 = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + const input2 = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input1, { pinned: true }); + await modalPart.activeGroup.openEditor(input2, { pinned: true }); + + assert.strictEqual(modalPart.activeGroup.count, 2); + + // Move editors from modal to main part group + const targetGroup = parts.mainPart.activeGroup; + for (const group of modalPart.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE)) { + group.moveEditors(group.editors.map(editor => ({ editor, options: { preserveFocus: true } })), targetGroup); + } + + // Editors should be in the target group now + assert.strictEqual(targetGroup.count, 2); + assert.strictEqual(modalPart.activeGroup.count, 0); + + // Close modal + modalPart.close(); + assert.strictEqual(parts.activeModalEditorPart, undefined); + }); + ensureNoDisposablesAreLeakedInTestSuite(); });