* Implements https://github.com/microsoft/vscode/issues/271133

* Fixes cyclic file dependency

* Fixes test
This commit is contained in:
Henning Dieterichs 2026-01-30 14:04:02 +01:00 committed by GitHub
parent 086eda1a35
commit 37ac613efa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 419 additions and 89 deletions

View File

@ -0,0 +1,17 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ICodeEditor } from '../../../../browser/editorBrowser.js';
import type { InlineCompletionsController } from './inlineCompletionsController.js';
let _getInlineCompletionsController: ((editor: ICodeEditor) => InlineCompletionsController | null) | undefined;
export function getInlineCompletionsController(editor: ICodeEditor): InlineCompletionsController | null {
return _getInlineCompletionsController?.(editor) ?? null;
}
export function setInlineCompletionsControllerGetter(getter: (editor: ICodeEditor) => InlineCompletionsController | null): void {
_getInlineCompletionsController = getter;
}

View File

@ -41,8 +41,11 @@ import { ObservableSuggestWidgetAdapter } from '../model/suggestWidgetAdapter.js
import { ObservableContextKeyService } from '../utils.js';
import { InlineSuggestionsView } from '../view/inlineSuggestionsView.js';
import { inlineSuggestCommitId } from './commandIds.js';
import { setInlineCompletionsControllerGetter } from './common.js';
import { InlineCompletionContextKeys } from './inlineCompletionContextKeys.js';
setInlineCompletionsControllerGetter((editor) => InlineCompletionsController.get(editor));
export class InlineCompletionsController extends Disposable {
private static readonly _instances = new Set<InlineCompletionsController>();
@ -444,7 +447,7 @@ export class InlineCompletionsController extends Disposable {
// Only if this controller is in focus can we cancel others.
if (this._focusIsInEditorOrMenu.get()) {
for (const ctrl of InlineCompletionsController._instances) {
if (ctrl !== this) {
if (ctrl !== this && !ctrl._focusIsInEditorOrMenu.get()) {
ctrl.model.get()?.stop('automatic', tx);
}
}

View File

@ -53,6 +53,7 @@ import { URI } from '../../../../../base/common/uri.js';
import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';
import { IDefaultAccount } from '../../../../../base/common/defaultAccount.js';
import { Schemas } from '../../../../../base/common/network.js';
import { getInlineCompletionsController } from '../controller/common.js';
export class InlineCompletionsModel extends Disposable {
private readonly _source;
@ -361,6 +362,7 @@ export class InlineCompletionsModel extends Disposable {
return true;
},
},
}, (reader, changeSummary) => {
this._source.clearOperationOnTextModelChange.read(reader); // Make sure the clear operation runs before the fetch operation
this._noDelaySignal.read(reader);
@ -645,9 +647,12 @@ export class InlineCompletionsModel extends Disposable {
const stringEdit = inlineEditResult.action?.kind === 'edit' ? inlineEditResult.action.stringEdit : undefined;
const replacements = stringEdit ? TextEdit.fromStringEdit(stringEdit, new TextModelText(this.textModel)).replacements : [];
const nextEditUri = (item.inlineEdit?.command?.id === 'vscode.open' || item.inlineEdit?.command?.id === '_workbench.open') &&
let nextEditUri = (item.inlineEdit?.command?.id === 'vscode.open' || item.inlineEdit?.command?.id === '_workbench.open') &&
// eslint-disable-next-line local/code-no-any-casts
item.inlineEdit?.command.arguments?.length ? URI.from(<any>item.inlineEdit?.command.arguments[0]) : undefined;
if (!inlineEditResult.originalTextRef.targets(this.textModel)) {
nextEditUri = inlineEditResult.originalTextRef.uri;
}
return { kind: 'inlineEdit', inlineSuggestion: inlineEditResult, edits: replacements, cursorAtInlineEdit, nextEditUri };
}
@ -925,7 +930,18 @@ export class InlineCompletionsModel extends Disposable {
try {
let followUpTrigger = false;
editor.pushUndoStop();
if (isNextEditUri) {
if (!completion.originalTextRef.targets(this.textModel)) {
// The edit targets a different document, open it and transplant the completion
const targetEditor = await this._codeEditorService.openCodeEditor({ resource: completion.originalTextRef.uri }, this._editor);
if (targetEditor) {
const controller = getInlineCompletionsController(targetEditor);
const m = controller?.model.get();
targetEditor.focus();
m?.transplantCompletion(completion);
targetEditor.revealLineInCenter(completion.targetRange.startLineNumber);
}
} else if (isNextEditUri) {
// Do nothing
} else if (completion.action?.kind === 'edit') {
const action = completion.action;
@ -1139,6 +1155,13 @@ export class InlineCompletionsModel extends Disposable {
if (!s) { return; }
const suggestion = s.inlineSuggestion;
if (!suggestion.originalTextRef.targets(this.textModel)) {
this.accept(this._editor);
return;
}
suggestion.addRef();
try {
transaction(tx => {
@ -1174,6 +1197,20 @@ export class InlineCompletionsModel extends Disposable {
public async handleInlineSuggestionShown(inlineCompletion: InlineSuggestionItem, viewKind: InlineCompletionViewKind, viewData: InlineCompletionViewData, timeWhenShown: number): Promise<void> {
await inlineCompletion.reportInlineEditShown(this._commandService, viewKind, viewData, this.textModel, timeWhenShown);
}
/**
* Transplants an inline completion from another model to this one.
* Used for cross-file inline edits.
*/
public transplantCompletion(item: InlineSuggestionItem): void {
item.addRef();
transaction(tx => {
this._source.seedWithCompletion(item, tx);
this._isActive.set(true, tx);
this._inAcceptFlow.set(true, tx);
this.dontRefetchSignal.trigger(tx);
});
}
}
interface Repro {

View File

@ -10,7 +10,7 @@ import { CancellationTokenSource } from '../../../../../base/common/cancellation
import { equalsIfDefined, thisEqualsC } from '../../../../../base/common/equals.js';
import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
import { cloneAndChange } from '../../../../../base/common/objects.js';
import { derived, IObservable, IObservableWithChange, ITransaction, observableValue, recordChangesLazy, transaction } from '../../../../../base/common/observable.js';
import { derived, IObservable, IObservableWithChange, ITransaction, observableValue, recordChangesLazy, runOnChange, transaction } from '../../../../../base/common/observable.js';
// eslint-disable-next-line local/code-no-deep-import-of-internal
import { observableReducerSettable } from '../../../../../base/common/observableInternal/experimental/reducer.js';
import { isDefined, isObject } from '../../../../../base/common/types.js';
@ -30,6 +30,7 @@ import { ITextModel } from '../../../../common/model.js';
import { offsetEditFromContentChanges } from '../../../../common/model/textModelStringEdit.js';
import { isCompletionsEnabledFromObject } from '../../../../common/services/completionsEnablement.js';
import { IFeatureDebounceInformation } from '../../../../common/services/languageFeatureDebounce.js';
import { ITextModelService } from '../../../../common/services/resolverService.js';
import { IModelContentChangedEvent } from '../../../../common/textModelEvents.js';
import { formatRecordableLogEntry, IRecordableEditorLogEntry, IRecordableLogEntry, StructuredLogger } from '../structuredLogger.js';
import { InlineCompletionEndOfLifeEvent, sendInlineCompletionsEndOfLifeTelemetry } from '../telemetry.js';
@ -37,6 +38,7 @@ import { wait } from '../utils.js';
import { InlineSuggestionIdentity, InlineSuggestionItem } from './inlineSuggestionItem.js';
import { InlineCompletionContextWithoutUuid, InlineSuggestRequestInfo, provideInlineCompletions, runWhenCancelled } from './provideInlineCompletions.js';
import { RenameSymbolProcessor } from './renameSymbolProcessor.js';
import { TextModelValueReference } from './textModelValueReference.js';
export class InlineCompletionsSource extends Disposable {
private static _requestId = 0;
@ -93,6 +95,7 @@ export class InlineCompletionsSource extends Disposable {
@IConfigurationService private readonly _configurationService: IConfigurationService,
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
@ITextModelService private readonly _textModelService: ITextModelService,
) {
super();
this._loggingEnabled = observableConfigValue('editor.inlineSuggest.logFetch', false, this._configurationService).recomputeInitiallyAndOnChange(this._store);
@ -253,7 +256,30 @@ export class InlineCompletionsSource extends Disposable {
}
item.addPerformanceMarker('providerReturned');
const i = InlineSuggestionItem.create(item, this._textModel);
const targetUri = item.action?.uri;
let targetModel: ITextModel;
let disposable: IDisposable | undefined;
if (targetUri && targetUri.toString() !== this._textModel.uri.toString()) {
const modelRef = await this._textModelService.createModelReference(targetUri);
targetModel = modelRef.object.textEditorModel;
disposable = modelRef;
} else {
targetModel = this._textModel;
disposable = undefined;
}
const ref = TextModelValueReference.snapshot(targetModel);
const i = InlineSuggestionItem.create(item, ref);
if (disposable) {
const s = runOnChange(i.identity.onDispose, () => {
disposable?.dispose();
s.dispose();
});
}
item.addPerformanceMarker('itemCreated');
providerSuggestions.push(i);
// Stop after first visible inline completion
@ -402,6 +428,9 @@ export class InlineCompletionsSource extends Disposable {
}
public clear(tx: ITransaction): void {
if (this._store.isDisposed) {
return;
}
this._updateOperation.clear();
const v = this._state.get();
this._state.set({
@ -434,6 +463,20 @@ export class InlineCompletionsSource extends Disposable {
});
}
/**
* Seeds the inline completions with an external inline completion item.
* Used when transplanting a completion from one model to another (cross-file edits).
*/
public seedWithCompletion(item: InlineSuggestionItem, tx: ITransaction): void {
const s = this._state.get();
this._state.set({
inlineCompletions: new InlineCompletionsState([item], undefined),
suggestWidgetInlineCompletions: InlineCompletionsState.createEmpty(),
}, tx);
s.inlineCompletions.dispose();
s.suggestWidgetInlineCompletions.dispose();
}
private sendInlineCompletionsRequestTelemetry(
requestResponseInfo: RequestResponseData
): void {
@ -594,12 +637,12 @@ class InlineCompletionsState extends Disposable {
public readonly inlineCompletions: readonly InlineSuggestionItem[],
public readonly request: UpdateRequest | undefined,
) {
for (const inlineCompletion of inlineCompletions) {
super();
for (const inlineCompletion of this.inlineCompletions) {
inlineCompletion.addRef();
}
super();
this._register({
dispose: () => {
for (const inlineCompletion of this.inlineCompletions) {

View File

@ -6,7 +6,6 @@
import { BugIndicatingError } from '../../../../../base/common/errors.js';
import { IObservable, ITransaction, observableSignal, observableValue } from '../../../../../base/common/observable.js';
import { commonPrefixLength, commonSuffixLength, splitLines } from '../../../../../base/common/strings.js';
import { URI } from '../../../../../base/common/uri.js';
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
import { ISingleEditOperation } from '../../../../common/core/editOperation.js';
import { applyEditsToRanges, StringEdit, StringReplacement } from '../../../../common/core/edits/stringEdit.js';
@ -27,13 +26,14 @@ import { computeEditKind, InlineSuggestionEditKind } from './editKind.js';
import { inlineCompletionIsVisible } from './inlineCompletionIsVisible.js';
import { IInlineSuggestDataAction, IInlineSuggestDataActionEdit, InlineSuggestData, InlineSuggestionList, PartialAcceptance, RenameInfo, SnippetInfo } from './provideInlineCompletions.js';
import { InlineSuggestAlternativeAction } from './InlineSuggestAlternativeAction.js';
import { TextModelValueReference } from './textModelValueReference.js';
export type InlineSuggestionItem = InlineEditItem | InlineCompletionItem;
export namespace InlineSuggestionItem {
export function create(
data: InlineSuggestData,
textModel: ITextModel,
textModel: TextModelValueReference,
shouldDiffEdit: boolean = true, // TODO@benibenj it should only be created once and hence not meeded to be passed here
): InlineSuggestionItem {
if (!data.isInlineEdit && !data.action?.uri && data.action?.kind === 'edit') {
@ -51,7 +51,7 @@ export interface IInlineSuggestionActionEdit {
textReplacement: TextReplacement;
snippetInfo: SnippetInfo | undefined;
stringEdit: StringEdit;
uri: URI | undefined;
target: TextModelValueReference;
alternativeAction: InlineSuggestAlternativeAction | undefined;
}
@ -59,11 +59,18 @@ export interface IInlineSuggestionActionJumpTo {
kind: 'jumpTo';
position: Position;
offset: number;
uri: URI | undefined;
target: TextModelValueReference;
}
function hashInlineSuggestionAction(action: InlineSuggestionAction | undefined): string {
const obj = action?.kind === 'edit' ? { ...action, alternativeAction: InlineSuggestAlternativeAction.toString(action.alternativeAction) } : action;
const obj = action?.kind === 'edit' ? {
...action, alternativeAction: InlineSuggestAlternativeAction.toString(action.alternativeAction),
target: action?.target.uri.toString(),
} : {
...action,
target: action?.target.uri.toString(),
};
return JSON.stringify(obj);
}
@ -72,6 +79,11 @@ abstract class InlineSuggestionItemBase {
protected readonly _data: InlineSuggestData,
public readonly identity: InlineSuggestionIdentity,
public readonly hint: InlineSuggestHint | undefined,
/**
* Reference to the text model this item targets.
* For cross-file edits, this may differ from the current editor's model.
*/
public readonly originalTextRef: TextModelValueReference,
) {
}
@ -193,7 +205,7 @@ export class InlineSuggestionIdentity {
return this._jumpedTo;
}
private _refCount = 1;
private _refCount = 0;
public readonly id = 'InlineCompletionIdentity' + InlineSuggestionIdentity.idCounter++;
addRef() {
@ -248,11 +260,11 @@ export class InlineSuggestHint {
export class InlineCompletionItem extends InlineSuggestionItemBase {
public static create(
data: InlineSuggestData,
textModel: ITextModel,
textModel: TextModelValueReference,
action: IInlineSuggestDataActionEdit,
): InlineCompletionItem {
const identity = new InlineSuggestionIdentity();
const transformer = getPositionOffsetTransformerFromTextModel(textModel);
const transformer = textModel.getTransformer();
const insertText = action.insertText.replace(/\r\n|\r|\n/g, textModel.getEOL());
@ -262,7 +274,7 @@ export class InlineCompletionItem extends InlineSuggestionItemBase {
const displayLocation = data.hint ? InlineSuggestHint.create(data.hint) : undefined;
return new InlineCompletionItem(edit, trimmedEdit, textEdit, textEdit.range, action.snippetInfo, data.additionalTextEdits, data, identity, displayLocation);
return new InlineCompletionItem(edit, trimmedEdit, textEdit, textEdit.range, action.snippetInfo, data.additionalTextEdits, data, identity, displayLocation, textModel);
}
public readonly isInlineEdit = false;
@ -278,8 +290,9 @@ export class InlineCompletionItem extends InlineSuggestionItemBase {
data: InlineSuggestData,
identity: InlineSuggestionIdentity,
displayLocation: InlineSuggestHint | undefined,
originalTextRef: TextModelValueReference,
) {
super(data, identity, displayLocation);
super(data, identity, displayLocation, originalTextRef);
}
override get action(): IInlineSuggestionActionEdit {
@ -288,8 +301,8 @@ export class InlineCompletionItem extends InlineSuggestionItemBase {
textReplacement: this.getSingleTextEdit(),
snippetInfo: this.snippetInfo,
stringEdit: new StringEdit([this._trimmedEdit]),
uri: undefined,
alternativeAction: undefined,
target: this.originalTextRef,
};
}
@ -309,11 +322,17 @@ export class InlineCompletionItem extends InlineSuggestionItemBase {
this.additionalTextEdits,
this._data,
identity,
this.hint
this.hint,
this.originalTextRef
);
}
override withEdit(textModelEdit: StringEdit, textModel: ITextModel): InlineCompletionItem | undefined {
// If the edit is to a different model than our target, it's a noop
if (!this.originalTextRef.targets(textModel)) {
return this; // unchanged
}
const newEditRange = applyEditsToRanges([this._edit.replaceRange], textModelEdit);
if (newEditRange.length === 0) {
return undefined;
@ -341,7 +360,8 @@ export class InlineCompletionItem extends InlineSuggestionItemBase {
this.additionalTextEdits,
this._data,
this.identity,
newDisplayLocation
newDisplayLocation,
this.originalTextRef
);
}
@ -371,19 +391,18 @@ export class InlineCompletionItem extends InlineSuggestionItemBase {
export class InlineEditItem extends InlineSuggestionItemBase {
public static create(
data: InlineSuggestData,
textModel: ITextModel,
textModel: TextModelValueReference,
shouldDiffEdit: boolean = true,
): InlineEditItem {
let action: InlineSuggestionAction | undefined;
let edits: SingleUpdatedNextEdit[] = [];
if (data.action?.kind === 'edit') {
const offsetEdit = shouldDiffEdit ? getDiffedStringEdit(textModel, data.action.range, data.action.insertText) : getStringEdit(textModel, data.action.range, data.action.insertText); // TODO compute async
const text = new TextModelText(textModel);
const textEdit = TextEdit.fromStringEdit(offsetEdit, text);
const singleTextEdit = offsetEdit.isEmpty() ? new TextReplacement(new Range(1, 1, 1, 1), '') : textEdit.toReplacement(text); // FIXME: .toReplacement() can throw because offsetEdit is empty because we get an empty diff in getStringEdit after diffing
const textEdit = TextEdit.fromStringEdit(offsetEdit, textModel);
const singleTextEdit = offsetEdit.isEmpty() ? new TextReplacement(new Range(1, 1, 1, 1), '') : textEdit.toReplacement(textModel); // FIXME: .toReplacement() can throw because offsetEdit is empty because we get an empty diff in getStringEdit after diffing
edits = offsetEdit.replacements.map(edit => {
const replacedRange = Range.fromPositions(textModel.getPositionAt(edit.replaceRange.start), textModel.getPositionAt(edit.replaceRange.endExclusive));
const replacedRange = Range.fromPositions(textModel.getPositionAt(edit.replaceRange.start), textModel.getTransformer().getPosition(edit.replaceRange.endExclusive));
const replacedText = textModel.getValueInRange(replacedRange);
return SingleUpdatedNextEdit.create(edit, replacedText);
});
@ -393,15 +412,15 @@ export class InlineEditItem extends InlineSuggestionItemBase {
snippetInfo: data.action.snippetInfo,
stringEdit: offsetEdit,
textReplacement: singleTextEdit,
uri: data.action.uri,
alternativeAction: data.action.alternativeAction,
target: textModel,
};
} else if (data.action?.kind === 'jumpTo') {
action = {
kind: 'jumpTo',
position: data.action.position,
offset: textModel.getOffsetAt(data.action.position),
uri: data.action.uri,
offset: textModel.getTransformer().getOffset(data.action.position),
target: textModel,
};
} else {
action = undefined;
@ -413,7 +432,7 @@ export class InlineEditItem extends InlineSuggestionItemBase {
const identity = new InlineSuggestionIdentity();
const hint = data.hint ? InlineSuggestHint.create(data.hint) : undefined;
return new InlineEditItem(action, data, identity, edits, hint, false, textModel.getVersionId());
return new InlineEditItem(action, data, identity, edits, hint, false, textModel.getVersionId(), textModel);
}
public readonly snippetInfo: SnippetInfo | undefined = undefined;
@ -430,8 +449,9 @@ export class InlineEditItem extends InlineSuggestionItemBase {
hint: InlineSuggestHint | undefined,
private readonly _lastChangePartOfInlineEdit = false,
private readonly _inlineEditModelVersion: number,
originalTextRef: TextModelValueReference,
) {
super(data, identity, hint);
super(data, identity, hint, originalTextRef);
}
public get updatedEditModelVersion(): number { return this._inlineEditModelVersion; }
@ -450,6 +470,7 @@ export class InlineEditItem extends InlineSuggestionItemBase {
this.hint,
this._lastChangePartOfInlineEdit,
this._inlineEditModelVersion,
this.originalTextRef,
);
}
@ -459,6 +480,11 @@ export class InlineEditItem extends InlineSuggestionItemBase {
}
override withEdit(textModelChanges: StringEdit, textModel: ITextModel): InlineEditItem | undefined {
// If the edit is to a different model than our target, it's a noop
if (!this.originalTextRef.targets(textModel)) {
return this; // unchanged
}
const edit = this._applyTextModelChanges(textModelChanges, this._edits, textModel);
return edit;
}
@ -502,8 +528,8 @@ export class InlineEditItem extends InlineSuggestionItemBase {
textReplacement: newTextEdit,
snippetInfo: this.snippetInfo,
stringEdit: newEdit,
uri: this.action.uri,
alternativeAction: this.action.alternativeAction,
target: this.originalTextRef,
};
} else if (this.action?.kind === 'jumpTo') {
const jumpToOffset = this.action.offset;
@ -517,7 +543,7 @@ export class InlineEditItem extends InlineSuggestionItemBase {
kind: 'jumpTo',
position: newJumpToPosition,
offset: newJumpToOffset,
uri: this.action.uri,
target: this.originalTextRef,
};
} else {
newAction = undefined;
@ -539,6 +565,7 @@ export class InlineEditItem extends InlineSuggestionItemBase {
newDisplayLocation,
lastChangePartOfInlineEdit,
inlineEditModelVersion,
this.originalTextRef,
);
}
@ -551,9 +578,9 @@ export class InlineEditItem extends InlineSuggestionItemBase {
}
}
function getDiffedStringEdit(textModel: ITextModel, editRange: Range, replaceText: string): StringEdit {
function getDiffedStringEdit(textModel: TextModelValueReference, editRange: Range, replaceText: string): StringEdit {
const eol = textModel.getEOL();
const editOriginalText = textModel.getValueInRange(editRange);
const editOriginalText = textModel.getValueOfRange(editRange);
const editReplaceText = replaceText.replace(/\r\n|\r|\n/g, eol);
const diffAlgorithm = linesDiffComputers.getDefault();
@ -580,12 +607,12 @@ function getDiffedStringEdit(textModel: ITextModel, editRange: Range, replaceTex
const offsetEdit = new StringEdit(
innerChanges.map(c => {
const rangeInModel = addRangeToPos(editRange.getStartPosition(), c.originalRange);
const originalRange = getPositionOffsetTransformerFromTextModel(textModel).getOffsetRange(rangeInModel);
const originalRange = textModel.getTransformer().getOffsetRange(rangeInModel);
const replaceText = modifiedText.getValueOfRange(c.modifiedRange);
const edit = new StringReplacement(originalRange, replaceText);
const originalText = textModel.getValueInRange(rangeInModel);
const originalText = textModel.getValueOfRange(rangeInModel);
return reshapeInlineEdit(edit, originalText, innerChanges.length, textModel);
})
);
@ -593,9 +620,9 @@ function getDiffedStringEdit(textModel: ITextModel, editRange: Range, replaceTex
return offsetEdit;
}
function getStringEdit(textModel: ITextModel, editRange: Range, replaceText: string): StringEdit {
function getStringEdit(textModel: TextModelValueReference, editRange: Range, replaceText: string): StringEdit {
return new StringEdit([new StringReplacement(
getPositionOffsetTransformerFromTextModel(textModel).getOffsetRange(editRange),
textModel.getTransformer().getOffsetRange(editRange),
replaceText
)]);
}
@ -728,7 +755,7 @@ class SingleUpdatedNextEdit {
}
}
function reshapeInlineCompletion(edit: StringReplacement, textModel: ITextModel): StringReplacement {
function reshapeInlineCompletion(edit: StringReplacement, textModel: TextModelValueReference): StringReplacement {
// If the insertion is a multi line insertion starting on the next line
// Move it forwards so that the multi line insertion starts on the current line
const eol = textModel.getEOL();
@ -739,7 +766,7 @@ function reshapeInlineCompletion(edit: StringReplacement, textModel: ITextModel)
return edit;
}
function reshapeInlineEdit(edit: StringReplacement, originalText: string, totalInnerEdits: number, textModel: ITextModel): StringReplacement {
function reshapeInlineEdit(edit: StringReplacement, originalText: string, totalInnerEdits: number, textModel: TextModelValueReference): StringReplacement {
// TODO: EOL are not properly trimmed by the diffAlgorithm #12680
const eol = textModel.getEOL();
if (edit.newText.endsWith(eol) && originalText.endsWith(eol)) {
@ -750,7 +777,7 @@ function reshapeInlineEdit(edit: StringReplacement, originalText: string, totalI
// If the insertion ends with a new line and is inserted at the start of a line which has text,
// we move the insertion to the end of the previous line if possible
if (totalInnerEdits === 1 && edit.replaceRange.isEmpty && edit.newText.includes(eol)) {
const startPosition = textModel.getPositionAt(edit.replaceRange.start);
const startPosition = textModel.getTransformer().getPosition(edit.replaceRange.start);
const hasTextOnInsertionLine = textModel.getLineLength(startPosition.lineNumber) !== 0;
if (hasTextOnInsertionLine) {
edit = reshapeMultiLineInsertion(edit, textModel);
@ -777,7 +804,7 @@ function reshapeInlineEdit(edit: StringReplacement, originalText: string, totalI
return edit;
}
function reshapeMultiLineInsertion(edit: StringReplacement, textModel: ITextModel): StringReplacement {
function reshapeMultiLineInsertion(edit: StringReplacement, textModel: TextModelValueReference): StringReplacement {
if (!edit.replaceRange.isEmpty) {
throw new BugIndicatingError('Unexpected original range');
}
@ -787,7 +814,7 @@ function reshapeMultiLineInsertion(edit: StringReplacement, textModel: ITextMode
}
const eol = textModel.getEOL();
const startPosition = textModel.getPositionAt(edit.replaceRange.start);
const startPosition = textModel.getTransformer().getPosition(edit.replaceRange.start);
const startColumn = startPosition.column;
const startLineNumber = startPosition.lineNumber;

View File

@ -30,6 +30,7 @@ import { IRenameSymbolTrackerService } from '../../../../browser/services/rename
import type { URI } from '../../../../../base/common/uri.js';
import type { ICodeEditor } from '../../../../browser/editorBrowser.js';
import { ICodeEditorService } from '../../../../browser/services/codeEditorService.js';
import { TextModelValueReference } from './textModelValueReference.js';
enum RenameKind {
no = 'no',
@ -533,7 +534,8 @@ export class RenameSymbolProcessor extends Disposable {
uri: textModel.uri
};
return InlineSuggestionItem.create(suggestItem.withAction(renameAction), textModel, false);
const ref = TextModelValueReference.snapshot(textModel);
return InlineSuggestionItem.create(suggestItem.withAction(renameAction), ref, false);
}
private async checkRenamePrecondition(suggestItem: InlineSuggestionItem, textModel: ITextModel, position: Position, oldName: string, newName: string, lastSymbolRename: IRange | undefined): Promise<PrepareNesRenameResult> {

View File

@ -0,0 +1,90 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { onUnexpectedError } from '../../../../../base/common/errors.js';
import { URI } from '../../../../../base/common/uri.js';
import { Position } from '../../../../common/core/position.js';
import { Range } from '../../../../common/core/range.js';
import { AbstractText } from '../../../../common/core/text/abstractText.js';
import { TextLength } from '../../../../common/core/text/textLength.js';
import { ITextModel } from '../../../../common/model.js';
/**
* An immutable view of a text model at a specific version.
* Like TextModelText but throws if the underlying model has changed.
* This ensures data read from the reference is consistent with
* the version at construction time.
*/
export class TextModelValueReference extends AbstractText {
private readonly _version: number;
static snapshot(textModel: ITextModel): TextModelValueReference {
return new TextModelValueReference(textModel);
}
private constructor(private readonly _textModel: ITextModel) {
super();
this._version = _textModel.getVersionId();
}
get uri(): URI {
return this._textModel.uri;
}
get version(): number {
return this._version;
}
private _assertValid(): void {
if (this._textModel.getVersionId() !== this._version) {
onUnexpectedError(new Error(`TextModel has changed: expected version ${this._version}, got ${this._textModel.getVersionId()}`));
// TODO: throw here!
}
}
targets(textModel: ITextModel): boolean {
return this._textModel.uri.toString() === textModel.uri.toString();
}
override getValueOfRange(range: Range): string {
this._assertValid();
return this._textModel.getValueInRange(range);
}
override getLineLength(lineNumber: number): number {
this._assertValid();
return this._textModel.getLineLength(lineNumber);
}
get length(): TextLength {
this._assertValid();
const lastLineNumber = this._textModel.getLineCount();
const lastLineLen = this._textModel.getLineLength(lastLineNumber);
return new TextLength(lastLineNumber - 1, lastLineLen);
}
getEOL(): string {
this._assertValid();
return this._textModel.getEOL();
}
getPositionAt(offset: number): Position {
this._assertValid();
return this._textModel.getPositionAt(offset);
}
getValueInRange(range: Range): string {
this._assertValid();
return this._textModel.getValueInRange(range);
}
getVersionId(): number {
return this._version;
}
dangerouslyGetUnderlyingModel(): ITextModel {
return this._textModel;
}
}

View File

@ -7,9 +7,9 @@ import { LineReplacement } from '../../../../../common/core/edits/lineEdit.js';
import { TextEdit } from '../../../../../common/core/edits/textEdit.js';
import { Position } from '../../../../../common/core/position.js';
import { LineRange } from '../../../../../common/core/ranges/lineRange.js';
import { AbstractText } from '../../../../../common/core/text/abstractText.js';
import { InlineCompletionCommand } from '../../../../../common/languages.js';
import { InlineSuggestionAction, InlineSuggestionItem } from '../../model/inlineSuggestionItem.js';
import { TextModelValueReference } from '../../model/textModelValueReference.js';
export class InlineEditWithChanges {
// TODO@hediet: Move the next 3 fields into the action
@ -35,7 +35,7 @@ export class InlineEditWithChanges {
}
constructor(
public readonly originalText: AbstractText,
public readonly originalText: TextModelValueReference,
public readonly action: InlineSuggestionAction | undefined,
public readonly edit: TextEdit | undefined,
public readonly cursorPosition: Position,

View File

@ -43,6 +43,8 @@ import { StringEdit } from '../../../../../common/core/edits/stringEdit.js';
import { OffsetRange } from '../../../../../common/core/ranges/offsetRange.js';
import { getPositionOffsetTransformerFromTextModel } from '../../../../../common/core/text/getPositionOffsetTransformerFromTextModel.js';
import { InlineCompletionEditorType } from '../../model/provideInlineCompletions.js';
import { TextModelValueReference } from '../../model/textModelValueReference.js';
import { URI } from '../../../../../../base/common/uri.js';
export class InlineEditsView extends Disposable {
private readonly _editorObs: ObservableCodeEditor;
@ -56,6 +58,7 @@ export class InlineEditsView extends Disposable {
view: ReturnType<typeof InlineEditsView.prototype._determineView>;
editorWidth: number;
timestamp: number;
uri: URI;
} | undefined;
private readonly _showLongDistanceHint: IObservable<boolean>;
@ -138,6 +141,7 @@ export class InlineEditsView extends Disposable {
model: this._simpleModel.read(reader)!,
inlineSuggestInfo: this._inlineSuggestInfo.read(reader)!,
nextCursorPosition: s.nextCursorPosition,
target: s.target,
}) : undefined),
this._previewTextModel,
this._tabAction,
@ -256,6 +260,10 @@ export class InlineEditsView extends Disposable {
public readonly displayRange = derived<LineRange | undefined>(this, reader => {
const state = this._uiState.read(reader);
if (!state) { return undefined; }
if (state.target.uri.toString() !== this._editorObs.model.read(reader)?.uri.toString()) {
return undefined;
}
if (state.state?.kind === 'custom') {
const range = state.state.displayLocation?.range;
if (!range) {
@ -284,6 +292,13 @@ export class InlineEditsView extends Disposable {
if (model.inlineEdit.action === undefined) {
return undefined;
}
if (model.inlineEdit.originalText.uri.toString() !== this._editorObs.model.read(reader)?.uri.toString()) {
return {
isVisible: true,
lineNumber: model.inlineEdit.cursorPosition.lineNumber,
};
}
if (this._currentInlineEditCache?.inlineSuggestionIdentity !== model.inlineEdit.inlineCompletion.identity) {
this._currentInlineEditCache = {
inlineSuggestionIdentity: model.inlineEdit.inlineCompletion.identity,
@ -307,6 +322,7 @@ export class InlineEditsView extends Disposable {
editorType: InlineCompletionEditorType;
longDistanceHint: ILongDistanceHint | undefined;
nextCursorPosition: Position | null;
target: TextModelValueReference;
} | undefined>(this, reader => {
const model = this._model.read(reader);
const textModel = this._editorObs.model.read(reader);
@ -380,6 +396,7 @@ export class InlineEditsView extends Disposable {
editorType: model.editorType,
longDistanceHint,
nextCursorPosition: nextCursorPosition,
target: inlineEdit.inlineCompletion.originalTextRef,
};
});
@ -433,7 +450,7 @@ export class InlineEditsView extends Disposable {
private _determineView(model: ModelPerInlineEdit, reader: IReader, diff: DetailedLineRangeMapping[], newText: AbstractText): InlineCompletionViewKind {
// Check if we can use the previous view if it is the same InlineCompletion as previously shown
const inlineEdit = model.inlineEdit;
const canUseCache = this._previousView?.id === this._getCacheId(model);
const canUseCache = this._previousView?.id === this._getCacheId(model) && this._previousView?.uri.toString() === this._editorObs.model.get()!.uri.toString();
const reconsiderViewEditorWidthChange = this._previousView?.editorWidth !== this._editorObs.layoutInfoWidth.read(reader) &&
(
this._previousView?.view === InlineCompletionViewKind.SideBySide ||
@ -449,8 +466,9 @@ export class InlineEditsView extends Disposable {
return InlineCompletionViewKind.WordReplacements;
}
const uri = action?.kind === 'edit' ? action.uri : undefined;
if (uri !== undefined) {
const targetUri = model.inlineEdit.inlineCompletion.originalTextRef.uri;
const currentUri = this._editorObs.model.read(reader)?.uri;
if (currentUri && targetUri.toString() !== currentUri.toString()) {
return InlineCompletionViewKind.Custom;
}
@ -550,14 +568,14 @@ export class InlineEditsView extends Disposable {
break;
}
}
this._previousView = { id: this._getCacheId(model), view, editorWidth: this._editor.getLayoutInfo().width, timestamp: Date.now() };
this._previousView = { id: this._getCacheId(model), view, editorWidth: this._editor.getLayoutInfo().width, timestamp: Date.now(), uri: this._editorObs.model.get()!.uri };
const inner = diff.flatMap(d => d.innerChanges ?? []);
const textModel = this._editor.getModel()!;
const stringChanges = inner.map(m => ({
originalRange: m.originalRange,
modifiedRange: m.modifiedRange,
original: textModel.getValueInRange(m.originalRange),
original: inlineEdit.originalText.getValueOfRange(m.originalRange),
modified: newText.getValueOfRange(m.modifiedRange)
}));

View File

@ -10,7 +10,6 @@ import { ICodeEditor } from '../../../../../browser/editorBrowser.js';
import { ObservableCodeEditor, observableCodeEditor } from '../../../../../browser/observableCodeEditor.js';
import { Range } from '../../../../../common/core/range.js';
import { TextReplacement, TextEdit } from '../../../../../common/core/edits/textEdit.js';
import { TextModelText } from '../../../../../common/model/textModelText.js';
import { InlineCompletionsModel } from '../../model/inlineCompletionsModel.js';
import { InlineEditWithChanges } from './inlineEditWithChanges.js';
import { ModelPerInlineEdit } from './inlineEditsModel.js';
@ -31,17 +30,15 @@ export class InlineEditsViewAndDiffProducer extends Disposable { // TODO: This c
if (!state) { return undefined; }
const action = state.inlineSuggestion.action;
const text = new TextModelText(textModel);
let diffEdits: TextEdit | undefined;
if (action?.kind === 'edit') {
const editOffset = action.stringEdit;
const t = state.inlineSuggestion.originalTextRef.getTransformer();
const edits = editOffset.replacements.map(e => {
const innerEditRange = Range.fromPositions(
textModel.getPositionAt(e.replaceRange.start),
textModel.getPositionAt(e.replaceRange.endExclusive)
t.getPosition(e.replaceRange.start),
t.getPosition(e.replaceRange.endExclusive)
);
return new TextReplacement(innerEditRange, e.newText);
});
@ -51,7 +48,7 @@ export class InlineEditsViewAndDiffProducer extends Disposable { // TODO: This c
}
return new InlineEditWithChanges(
text,
state.inlineSuggestion.originalTextRef,
action,
diffEdits,
model.primaryPosition.read(undefined),

View File

@ -36,6 +36,12 @@ import { InlineSuggestionGutterMenuData, SimpleInlineSuggestModel } from '../../
import { jumpToNextInlineEditId } from '../../../../controller/commandIds.js';
import { splitIntoContinuousLineRanges, WidgetLayoutConstants, WidgetOutline, WidgetPlacementContext } from './longDistnaceWidgetPlacement.js';
import { InlineCompletionEditorType } from '../../../../model/provideInlineCompletions.js';
import { basename } from '../../../../../../../../base/common/resources.js';
import { IModelService } from '../../../../../../../common/services/model.js';
import { ILanguageService } from '../../../../../../../common/languages/language.js';
import { getIconClasses } from '../../../../../../../common/services/getIconClasses.js';
import { FileKind } from '../../../../../../../../platform/files/common/files.js';
import { TextModelValueReference } from '../../../../model/textModelValueReference.js';
const BORDER_RADIUS = 6;
const MAX_WIDGET_WIDTH = { EMPTY_SPACE: 425, OVERLAY: 375 };
@ -65,6 +71,8 @@ export class InlineEditsLongDistanceHint extends Disposable implements IInlineEd
@IInstantiationService private readonly _instantiationService: IInstantiationService,
@IThemeService private readonly _themeService: IThemeService,
@IKeybindingService private readonly _keybindingService: IKeybindingService,
@IModelService private readonly _modelService: IModelService,
@ILanguageService private readonly _languageService: ILanguageService,
) {
super();
@ -114,6 +122,7 @@ export class InlineEditsLongDistanceHint extends Disposable implements IInlineEd
model: viewState.model,
inlineSuggestInfo: viewState.inlineSuggestInfo,
nextCursorPosition: viewState.nextCursorPosition,
target: viewState.target,
} satisfies ILongDistancePreviewProps;
}),
this._editor,
@ -361,7 +370,7 @@ export class InlineEditsLongDistanceHint extends Disposable implements IInlineEd
private readonly _widgetContent = derived(this, reader => // TODO@hediet: remove when n.div lazily creates previewEditor.element node
n.div({
class: 'inline-edits-long-distance-hint-widget',
class: ['inline-edits-long-distance-hint-widget', 'show-file-icons'],
style: {
position: 'absolute',
overflow: 'hidden',
@ -403,37 +412,56 @@ export class InlineEditsLongDistanceHint extends Disposable implements IInlineEd
return children;
}
// Outline Element
const source = this._originalOutlineSource.read(reader);
const originalTargetLineNumber = this._originalTargetLineNumber.read(reader);
const outlineItems = source?.getAt(originalTargetLineNumber, reader).slice(0, 1) ?? [];
const outlineElements: ChildNode[] = [];
if (outlineItems.length > 0) {
for (let i = 0; i < outlineItems.length; i++) {
const item = outlineItems[i];
const icon = SymbolKinds.toIcon(item.kind);
outlineElements.push(n.div({
class: 'breadcrumb-item',
style: { display: 'flex', alignItems: 'center', flex: '1 1 auto', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
}, [
renderIcon(icon),
'\u00a0',
item.name,
...(i === outlineItems.length - 1
? []
: [renderIcon(Codicon.chevronRight)]
)
]));
// Check if this is a cross-file edit
const currentUri = this._editorObs.model.read(reader)?.uri;
const targetUri = viewState.target.uri;
const isCrossFileEdit = targetUri && (!currentUri || targetUri.toString() !== currentUri.toString());
if (isCrossFileEdit) {
// For cross-file edits, show target filename instead of outline
const fileName = basename(targetUri);
const iconClasses = getIconClasses(this._modelService, this._languageService, targetUri, FileKind.FILE);
children.push(n.div({
class: 'target-file',
style: { display: 'flex', alignItems: 'center', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
}, [
n.elem('span', { class: iconClasses, style: { flexShrink: '0', marginRight: '4px' } }),
fileName,
]));
} else {
// Outline Element
const source = this._originalOutlineSource.read(reader);
const originalTargetLineNumber = this._originalTargetLineNumber.read(reader);
const outlineItems = source?.getAt(originalTargetLineNumber, reader).slice(0, 1) ?? [];
const outlineElements: ChildNode[] = [];
if (outlineItems.length > 0) {
for (let i = 0; i < outlineItems.length; i++) {
const item = outlineItems[i];
const icon = SymbolKinds.toIcon(item.kind);
outlineElements.push(n.div({
class: 'breadcrumb-item',
style: { display: 'flex', alignItems: 'center', flex: '1 1 auto', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
}, [
renderIcon(icon),
'\u00a0',
item.name,
...(i === outlineItems.length - 1
? []
: [renderIcon(Codicon.chevronRight)]
)
]));
}
}
children.push(n.div({ class: 'outline-elements', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, outlineElements));
}
children.push(n.div({ class: 'outline-elements', style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, outlineElements));
// Show Edit Direction
const arrowIcon = viewState.hint.lineNumber < originalTargetLineNumber ? Codicon.arrowDown : Codicon.arrowUp;
const originalTargetLineNumber = this._originalTargetLineNumber.read(reader);
const arrowIcon = isCrossFileEdit ? Codicon.arrowRight : (viewState.hint.lineNumber < originalTargetLineNumber ? Codicon.arrowDown : Codicon.arrowUp);
const keybinding = this._keybindingService.lookupKeybinding(jumpToNextInlineEditId);
let label = 'Go to suggestion';
let label = isCrossFileEdit ? 'Go to file' : 'Go to suggestion';
if (keybinding && keybinding.getLabel() === 'Tab') {
label = 'Tab to jump';
label = isCrossFileEdit ? 'Tab to open' : 'Tab to jump';
}
children.push(n.div({
class: 'go-to-label',
@ -483,6 +511,7 @@ export interface ILongDistanceViewState {
diff: DetailedLineRangeMapping[];
nextCursorPosition: Position | null;
editorType: InlineCompletionEditorType;
target: TextModelValueReference;
model: SimpleInlineSuggestModel;
inlineSuggestInfo: InlineSuggestionGutterMenuData;

View File

@ -24,12 +24,18 @@ import { InlineEditsGutterIndicator, InlineEditsGutterIndicatorData, InlineSugge
import { InlineEditTabAction } from '../../inlineEditsViewInterface.js';
import { classNames, maxContentWidthInRange } from '../../utils/utils.js';
import { JumpToView } from '../jumpToView.js';
import { TextModelValueReference } from '../../../../model/textModelValueReference.js';
export interface ILongDistancePreviewProps {
nextCursorPosition: Position | null; // assert: nextCursorPosition !== null xor diff.length > 0
diff: DetailedLineRangeMapping[];
model: SimpleInlineSuggestModel;
inlineSuggestInfo: InlineSuggestionGutterMenuData;
/**
* The URI of the file the edit targets.
* When undefined (or same as the editor's model URI), the edit targets the current file.
*/
target: TextModelValueReference;
}
export class LongDistancePreviewEditor extends Disposable {
@ -58,7 +64,7 @@ export class LongDistancePreviewEditor extends Disposable {
if (tm) {
// Avoid transitions from tm -> null -> tm, where tm -> tm would be a no-op.
this.previewEditor.setModel(tm);
this.previewEditor.setModel(tm.dangerouslyGetUnderlyingModel());
}
}));
@ -129,7 +135,12 @@ export class LongDistancePreviewEditor extends Disposable {
this.updatePreviewEditorEffect.recomputeInitiallyAndOnChange(this._store);
}
private readonly _state = derived(this, reader => {
private readonly _state = derived<{
mode: 'original' | 'modified';
visibleLineRange: LineRange;
textModel: TextModelValueReference | undefined;
diff: DetailedLineRangeMapping[];
} | undefined>(this, reader => {
const props = this._properties.read(reader);
if (!props) {
return undefined;
@ -151,7 +162,10 @@ export class LongDistancePreviewEditor extends Disposable {
}
}
const textModel = mode === 'original' ? this._parentEditorObs.model.read(reader) : this._previewTextModel;
const textModel = mode === 'modified'
? TextModelValueReference.snapshot(this._previewTextModel)
: props.target;
return {
mode,
visibleLineRange: visibleRange,

View File

@ -260,3 +260,14 @@
width: 4px;
}
/* File icon in long distance hint widget */
.inline-edits-long-distance-hint-widget.show-file-icons .target-file .file-icon::before {
display: inline-block;
width: 16px;
height: 16px;
vertical-align: text-bottom;
background-size: 16px;
background-position: center;
background-repeat: no-repeat;
}

View File

@ -5,7 +5,7 @@
import { timeout } from '../../../../../base/common/async.js';
import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';
import { Disposable, DisposableStore, IReference } from '../../../../../base/common/lifecycle.js';
import { CoreEditingCommands, CoreNavigationCommands } from '../../../../browser/coreCommands.js';
import { Position } from '../../../../common/core/position.js';
import { ITextModel } from '../../../../common/model.js';
@ -29,6 +29,10 @@ import { IBulkEditService } from '../../../../browser/services/bulkEditService.j
import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { IRenameSymbolTrackerService, NullRenameSymbolTrackerService } from '../../../../browser/services/renameSymbolTrackerService.js';
import { ITextModelService, IResolvedTextEditorModel } from '../../../../common/services/resolverService.js';
import { IModelService } from '../../../../common/services/model.js';
import { URI } from '../../../../../base/common/uri.js';
import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js';
export class MockInlineCompletionsProvider implements InlineCompletionsProvider {
private returnValue: InlineCompletion[] = [];
@ -264,6 +268,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel<T>(
setPreviewHandler: () => { throw new Error('IBulkEditService.setPreviewHandler not implemented'); },
_serviceBrand: undefined,
});
options.serviceCollection.set(ITextModelService, new SyncDescriptor(MockTextModelService));
options.serviceCollection.set(IDefaultAccountService, {
_serviceBrand: undefined,
onDidChangeDefaultAccount: Event.None,
@ -365,3 +370,40 @@ export class AnnotatedText extends AnnotatedString {
return this._transformer.getPosition(this.getMarkerOffset(markerIdx));
}
}
class MockTextModelService implements ITextModelService {
readonly _serviceBrand: undefined;
constructor(
@IModelService private readonly _modelService: IModelService,
) { }
async createModelReference(resource: URI): Promise<IReference<IResolvedTextEditorModel>> {
const model = this._modelService.getModel(resource);
if (!model) {
throw new Error(`MockTextModelService: Model not found for ${resource.toString()}`);
}
return {
object: {
textEditorModel: model,
getLanguageId: () => model.getLanguageId(),
isReadonly: () => false,
isDisposed: () => model.isDisposed(),
isResolved: () => true,
onWillDispose: model.onWillDispose,
resolve: async () => { },
createSnapshot: () => model.createSnapshot(),
dispose: () => { },
},
dispose: () => { },
};
}
registerTextModelContentProvider(): never {
throw new Error('MockTextModelService.registerTextModelContentProvider not implemented');
}
canHandleResource(): boolean {
return false;
}
}