Henning Dieterichs
2025-07-11 21:21:55 +02:00
committed by GitHub
parent df8db2972e
commit 092effaaeb
9 changed files with 115 additions and 67 deletions
@@ -347,6 +347,12 @@ export class TextReplacement {
return this.extendToCoverRange(newRange, initialValue);
}
public removeCommonPrefixAndSuffix(text: AbstractText): TextReplacement {
const prefix = this.removeCommonPrefix(text);
const suffix = prefix.removeCommonSuffix(text);
return suffix;
}
public removeCommonPrefix(text: AbstractText): TextReplacement {
const normalizedOriginalText = text.getValueOfRange(this.range).replaceAll('\r\n', '\n');
const normalizedModifiedText = this.text.replaceAll('\r\n', '\n');
@@ -360,6 +366,19 @@ export class TextReplacement {
return new TextReplacement(range, newText);
}
public removeCommonSuffix(text: AbstractText): TextReplacement {
const normalizedOriginalText = text.getValueOfRange(this.range).replaceAll('\r\n', '\n');
const normalizedModifiedText = this.text.replaceAll('\r\n', '\n');
const commonSuffixLen = commonSuffixLength(normalizedOriginalText, normalizedModifiedText);
const end = TextLength.ofText(normalizedOriginalText.substring(0, normalizedOriginalText.length - commonSuffixLen))
.addToPosition(this.range.getStartPosition());
const newText = normalizedModifiedText.substring(0, normalizedModifiedText.length - commonSuffixLen);
const range = Range.fromPositions(this.range.getStartPosition(), end);
return new TextReplacement(range, newText);
}
public isEffectiveDeletion(text: AbstractText): boolean {
let newText = this.text.replaceAll('\r\n', '\n');
let existingText = text.getValueOfRange(this.range).replaceAll('\r\n', '\n');
@@ -372,6 +391,12 @@ export class TextReplacement {
return newText === '';
}
public toString(): string {
const start = this.range.getStartPosition();
const end = this.range.getEndPosition();
return `(${start.lineNumber},${start.column} -> ${end.lineNumber},${end.column}): "${this.text}"`;
}
}
function rangeFromPositions(start: Position, end: Position): Range {
@@ -10,6 +10,7 @@ import { PositionOffsetTransformer } from './positionToOffsetImpl.js';
import { Range } from '../range.js';
import { LineRange } from '../ranges/lineRange.js';
import { TextLength } from '../text/textLength.js';
import { OffsetRange } from '../ranges/offsetRange.js';
export abstract class AbstractText {
abstract getValueOfRange(range: Range): string;
@@ -27,6 +28,10 @@ export abstract class AbstractText {
return this.getValueOfRange(this.length.toRange());
}
getValueOfOffsetRange(range: OffsetRange): string {
return this.getValueOfRange(this.getTransformer().getRange(range));
}
getLineLength(lineNumber: number): number {
return this.getValueOfRange(new Range(lineNumber, 1, lineNumber, Number.MAX_SAFE_INTEGER)).length;
}
@@ -39,12 +39,12 @@ export abstract class PositionOffsetTransformerBase {
return new Deps.deps.StringReplacement(this.getOffsetRange(edit.range), edit.text);
}
getSingleTextEdit(edit: StringReplacement): TextReplacement {
getTextReplacement(edit: StringReplacement): TextReplacement {
return new Deps.deps.TextReplacement(this.getRange(edit.replaceRange), edit.newText);
}
getTextEdit(edit: StringEdit): TextEdit {
const edits = edit.replacements.map(e => this.getSingleTextEdit(e));
const edits = edit.replacements.map(e => this.getTextReplacement(e));
return new Deps.deps.TextEdit(edits);
}
}
@@ -5,11 +5,11 @@
import { mapFindFirst } from '../../../../../base/common/arraysFind.js';
import { itemsEquals } from '../../../../../base/common/equals.js';
import { BugIndicatingError, onUnexpectedError, onUnexpectedExternalError } from '../../../../../base/common/errors.js';
import { BugIndicatingError, onUnexpectedExternalError } from '../../../../../base/common/errors.js';
import { Emitter } from '../../../../../base/common/event.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { IObservable, IObservableWithChange, IReader, ITransaction, autorun, constObservable, derived, derivedHandleChanges, derivedOpts, mapObservableArrayCached, observableFromEvent, observableSignal, observableValue, recomputeInitiallyAndOnChange, subtransaction, transaction } from '../../../../../base/common/observable.js';
import { commonPrefixLength, firstNonWhitespaceIndex } from '../../../../../base/common/strings.js';
import { firstNonWhitespaceIndex } from '../../../../../base/common/strings.js';
import { isDefined } from '../../../../../base/common/types.js';
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
@@ -33,7 +33,7 @@ import { IFeatureDebounceInformation } from '../../../../common/services/languag
import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js';
import { IModelContentChangedEvent } from '../../../../common/textModelEvents.js';
import { SnippetController2 } from '../../../snippet/browser/snippetController2.js';
import { addPositions, getEndPositionsAfterApplying, removeTextReplacementCommonSuffixPrefix, substringPos, subtractPositions } from '../utils.js';
import { getEndPositionsAfterApplying, removeTextReplacementCommonSuffixPrefix } from '../utils.js';
import { AnimatedValue, easeOutCubic, ObservableAnimatedValue } from './animation.js';
import { computeGhostText } from './computeGhostText.js';
import { GhostText, GhostTextOrReplacement, ghostTextOrReplacementEquals, ghostTextsOrReplacementsEqual } from './ghostText.js';
@@ -48,6 +48,8 @@ import { ICodeEditorService } from '../../../../browser/services/codeEditorServi
import { InlineCompletionViewData, InlineCompletionViewKind } from '../view/inlineEdits/inlineEditsViewInterface.js';
import { IInlineCompletionsService } from '../../../../browser/services/inlineCompletionsService.js';
import { TypingInterval } from './typingSpeed.js';
import { StringReplacement } from '../../../../common/core/edits/stringEdit.js';
import { OffsetRange } from '../../../../common/core/ranges/offsetRange.js';
export class InlineCompletionsModel extends Disposable {
private readonly _source;
@@ -576,10 +578,12 @@ export class InlineCompletionsModel extends Disposable {
const mode = this._suggestPreviewMode.read(reader);
const positions = this._positions.read(reader);
const edits = [fullEdit, ...getSecondaryEdits(this.textModel, positions, fullEdit)];
const ghostTexts = edits
.map((edit, idx) => computeGhostText(edit, model, mode, positions[idx], fullEditPreviewLength))
.filter(isDefined);
const allPotentialEdits = [fullEdit, ...getSecondaryEdits(this.textModel, positions, fullEdit)];
const validEditsAndGhostTexts = allPotentialEdits
.map((edit, idx) => ({ edit, ghostText: edit ? computeGhostText(edit, model, mode, positions[idx], fullEditPreviewLength) : undefined }))
.filter(({ edit, ghostText }) => edit !== undefined && ghostText !== undefined);
const edits = validEditsAndGhostTexts.map(({ edit }) => edit!);
const ghostTexts = validEditsAndGhostTexts.map(({ ghostText }) => ghostText!);
const primaryGhostText = ghostTexts[0] ?? new GhostText(fullEdit.range.endLineNumber, []);
return { kind: 'ghostText', edits, primaryGhostText, ghostTexts, inlineCompletion: augmentation?.completion, suggestItem };
} else {
@@ -590,10 +594,12 @@ export class InlineCompletionsModel extends Disposable {
const replacement = inlineCompletion.getSingleTextEdit();
const mode = this._inlineSuggestMode.read(reader);
const positions = this._positions.read(reader);
const edits = [replacement, ...getSecondaryEdits(this.textModel, positions, replacement)];
const ghostTexts = edits
.map((edit, idx) => computeGhostText(edit, model, mode, positions[idx], 0))
.filter(isDefined);
const allPotentialEdits = [replacement, ...getSecondaryEdits(this.textModel, positions, replacement)];
const validEditsAndGhostTexts = allPotentialEdits
.map((edit, idx) => ({ edit, ghostText: edit ? computeGhostText(edit, model, mode, positions[idx], 0) : undefined }))
.filter(({ edit, ghostText }) => edit !== undefined && ghostText !== undefined);
const edits = validEditsAndGhostTexts.map(({ edit }) => edit!);
const ghostTexts = validEditsAndGhostTexts.map(({ ghostText }) => ghostText!);
if (!ghostTexts[0]) { return undefined; }
return { kind: 'ghostText', edits, primaryGhostText: ghostTexts[0], ghostTexts, inlineCompletion, suggestItem: undefined };
}
@@ -955,7 +961,7 @@ export class InlineCompletionsModel extends Disposable {
const replaceRange = Range.fromPositions(cursorPosition, ghostTextPos);
const newText = editor.getModel()!.getValueInRange(replaceRange) + partialGhostTextVal;
const primaryEdit = new TextReplacement(replaceRange, newText);
const edits = [primaryEdit, ...getSecondaryEdits(this.textModel, positions, primaryEdit)];
const edits = [primaryEdit, ...getSecondaryEdits(this.textModel, positions, primaryEdit)].filter(isDefined);
const selections = getEndPositionsAfterApplying(edits).map(p => Selection.fromPositions(p));
editor.edit(TextEdit.fromParallelReplacementsUnsorted(edits), this._getMetadata(completion, type));
@@ -1045,36 +1051,40 @@ export enum VersionIdChangeReason {
Other,
}
export function getSecondaryEdits(textModel: ITextModel, positions: readonly Position[], primaryEdit: TextReplacement): TextReplacement[] {
export function getSecondaryEdits(textModel: ITextModel, positions: readonly Position[], primaryTextRepl: TextReplacement): (TextReplacement | undefined)[] {
if (positions.length === 1) {
// No secondary cursor positions
return [];
}
const primaryPosition = positions[0];
const secondaryPositions = positions.slice(1);
const primaryEditStartPosition = primaryEdit.range.getStartPosition();
const primaryEditEndPosition = primaryEdit.range.getEndPosition();
const replacedTextAfterPrimaryCursor = textModel.getValueInRange(
Range.fromPositions(primaryPosition, primaryEditEndPosition)
);
const positionWithinTextEdit = subtractPositions(primaryPosition, primaryEditStartPosition);
if (positionWithinTextEdit.lineNumber < 1) {
onUnexpectedError(new BugIndicatingError(
`positionWithinTextEdit line number should be bigger than 0.
Invalid subtraction between ${primaryPosition.toString()} and ${primaryEditStartPosition.toString()}`
));
return [];
}
const secondaryEditText = substringPos(primaryEdit.text, positionWithinTextEdit);
return secondaryPositions.map(pos => {
const posEnd = addPositions(subtractPositions(pos, primaryEditStartPosition), primaryEditEndPosition);
const textAfterSecondaryCursor = textModel.getValueInRange(
Range.fromPositions(pos, posEnd)
);
const l = commonPrefixLength(replacedTextAfterPrimaryCursor, textAfterSecondaryCursor);
const range = Range.fromPositions(pos, pos.delta(0, l));
return new TextReplacement(range, secondaryEditText);
});
const text = new TextModelText(textModel);
const textTransformer = text.getTransformer();
const primaryOffset = textTransformer.getOffset(positions[0]);
const secondaryOffsets = positions.slice(1).map(pos => textTransformer.getOffset(pos));
primaryTextRepl = primaryTextRepl.removeCommonPrefixAndSuffix(text);
const primaryStringRepl = textTransformer.getStringReplacement(primaryTextRepl);
const deltaFromOffsetToRangeStart = primaryStringRepl.replaceRange.start - primaryOffset;
const primaryContextRange = primaryStringRepl.replaceRange.join(OffsetRange.emptyAt(primaryOffset));
const primaryContextValue = text.getValueOfOffsetRange(primaryContextRange);
const replacements = secondaryOffsets.map(secondaryOffset => {
const newRangeStart = secondaryOffset + deltaFromOffsetToRangeStart;
const newRangeEnd = newRangeStart + primaryStringRepl.replaceRange.length;
const range = new OffsetRange(newRangeStart, newRangeEnd);
const contextRange = range.join(OffsetRange.emptyAt(secondaryOffset));
const contextValue = text.getValueOfOffsetRange(contextRange);
if (contextValue !== primaryContextValue) {
return undefined;
}
const stringRepl = new StringReplacement(range, primaryStringRepl.newText);
const repl = textTransformer.getTextReplacement(stringRepl);
return repl;
}).filter(isDefined);
return replacements;
}
class FadeoutDecoration extends Disposable {
@@ -191,7 +191,7 @@ export class InlineCompletionItem extends InlineSuggestionItemBase {
const edit = reshapeInlineCompletion(new StringReplacement(transformer.getOffsetRange(data.range), insertText), textModel);
const trimmedEdit = edit.removeCommonSuffixAndPrefix(textModel.getValue());
const textEdit = transformer.getSingleTextEdit(edit);
const textEdit = transformer.getTextReplacement(edit);
const displayLocation = data.displayLocation ? InlineSuggestDisplayLocation.create(data.displayLocation) : undefined;
@@ -242,7 +242,7 @@ export class InlineCompletionItem extends InlineSuggestionItemBase {
}
const newEdit = new StringReplacement(newEditRange[0], this._textEdit.text);
const positionOffsetTransformer = getPositionOffsetTransformerFromTextModel(textModel);
const newTextEdit = positionOffsetTransformer.getSingleTextEdit(newEdit);
const newTextEdit = positionOffsetTransformer.getTextReplacement(newEdit);
let newDisplayLocation = this.displayLocation;
if (newDisplayLocation) {
@@ -52,7 +52,7 @@ export function removeTextReplacementCommonSuffixPrefix(edits: readonly TextRepl
const text = textModel.getValue();
const stringReplacements = edits.map(edit => transformer.getStringReplacement(edit));
const minimalStringReplacements = stringReplacements.map(replacement => replacement.removeCommonSuffixPrefix(text));
return minimalStringReplacements.map(replacement => transformer.getSingleTextEdit(replacement));
return minimalStringReplacements.map(replacement => transformer.getTextReplacement(replacement));
}
export function convertItemsToStableObservables<T>(items: IObservable<readonly T[]>, store: DisposableStore): IObservable<IObservable<T>[]> {
@@ -5,10 +5,11 @@
import assert from 'assert';
import { Position } from '../../../../common/core/position.js';
import { getSecondaryEdits } from '../../browser/model/inlineCompletionsModel.js';
import { TextReplacement } from '../../../../common/core/edits/textEdit.js';
import { TextEdit, TextReplacement } from '../../../../common/core/edits/textEdit.js';
import { createTextModel } from '../../../../test/common/testTextModel.js';
import { Range } from '../../../../common/core/range.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { isDefined } from '../../../../../base/common/types.js';
suite('getSecondaryEdits', () => {
@@ -51,12 +52,7 @@ suite('getSecondaryEdits', () => {
'}'
].join('\n'));
const secondaryEdits = getSecondaryEdits(textModel, positions, primaryEdit);
assert.deepStrictEqual(secondaryEdits, [new TextReplacement(
new Range(4, 1, 4, 1), [
' return 0;',
'}'
].join('\n')
)]);
assert.deepStrictEqual(TextEdit.fromParallelReplacementsUnsorted(secondaryEdits.filter(isDefined)).toString(textModel.getValue()), "...ction fib(❰\n↦) {\n\t... 0;\n}❱");
textModel.dispose();
});
@@ -621,7 +621,7 @@ suite('Multi Cursor Support', () => {
editor.getValue(),
[
`console.log("hello");`,
`console.log("hello");`,
`console.log`,
``
].join('\n')
);
@@ -650,7 +650,7 @@ suite('Multi Cursor Support', () => {
editor.getValue(),
[
`console.log("hello");`,
`console.warn("hello");`,
`console.warn`,
``
].join('\n')
);
@@ -721,7 +721,7 @@ suite('Multi Cursor Support', () => {
editor.getValue(),
[
`for (let i)`,
`for (let i`,
`for `,
``
].join('\n')
);
@@ -754,7 +754,7 @@ suite('Multi Cursor Support', () => {
editor.getValue(),
[
`console.log("hello" + )`,
`console.warnnnn("hello" + `,
`console.warnnnn`,
``
].join('\n')
);
@@ -3,28 +3,40 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IDisposable } from '../../../base/common/lifecycle.js';
import { autorunOpts, IObservable, IReader, observableFromEventOpts } from '../../../base/common/observable.js';
import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js';
import { derivedOpts, IObservable, IReader, observableFromEventOpts } from '../../../base/common/observable.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { ContextKeyValue, IContextKeyService, RawContextKey } from '../../contextkey/common/contextkey.js';
/** Creates an observable update when a configuration key updates. */
export function observableConfigValue<T>(key: string, defaultValue: T, configurationService: IConfigurationService): IObservable<T> {
return observableFromEventOpts({ debugName: () => `Configuration Key "${key}"`, },
(handleChange) => configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(key)) {
handleChange(e);
}
}),
() => configurationService.getValue<T>(key) ?? defaultValue
);
function compute_$show2FramesUp() {
return observableFromEventOpts({ debugName: () => `Configuration Key "${key}"`, },
(handleChange) => configurationService.onDidChangeConfiguration(e => {
if (e.affectsConfiguration(key)) {
handleChange(e);
}
}),
() => configurationService.getValue<T>(key) ?? defaultValue
);
}
return compute_$show2FramesUp();
}
/** Update the configuration key with a value derived from observables. */
export function bindContextKey<T extends ContextKeyValue>(key: RawContextKey<T>, service: IContextKeyService, computeValue: (reader: IReader) => T): IDisposable {
const boundKey = key.bindTo(service);
return autorunOpts({ debugName: () => `Set Context Key "${key.key}"` }, reader => {
boundKey.set(computeValue(reader));
});
function compute_$show2FramesUp() {
const store = new DisposableStore();
derivedOpts({ debugName: () => `Set Context Key "${key.key}"` }, reader => {
const value = computeValue(reader);
boundKey.set(value);
return value;
}).recomputeInitiallyAndOnChange(store);
return store;
}
return compute_$show2FramesUp();
}