diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/common.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/common.ts new file mode 100644 index 00000000000..8fcb15ce0da --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/common.ts @@ -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; +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts index 7a0c70e0b0d..035f8b38421 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts @@ -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(); @@ -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); } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts index eee28998bb6..dc04e89e4cf 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -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(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 { 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 { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts index 83d4831495d..352af77e87f 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts @@ -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) { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts index 40660d6dbcd..02165506d6a 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts @@ -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; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts index 7946c817bfd..25ab7287379 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts @@ -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 { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/textModelValueReference.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/textModelValueReference.ts new file mode 100644 index 00000000000..1a973830d85 --- /dev/null +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/textModelValueReference.ts @@ -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; + } +} diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts index f806c1633a4..f532b3420a2 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts @@ -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, diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts index c1b20771380..73d400f7ad0 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts @@ -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; editorWidth: number; timestamp: number; + uri: URI; } | undefined; private readonly _showLongDistanceHint: IObservable; @@ -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(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) })); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts index 19c1b58ae42..773548f9706 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts @@ -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), diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/inlineEditsLongDistanceHint.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/inlineEditsLongDistanceHint.ts index 7ea5a9b3ffc..fa35b11b59e 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/inlineEditsLongDistanceHint.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/inlineEditsLongDistanceHint.ts @@ -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; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/longDistancePreviewEditor.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/longDistancePreviewEditor.ts index 902a06b1714..b4df474593b 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/longDistancePreviewEditor.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/longDistancePreviewEditor.ts @@ -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, diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css index 7070bae8bb5..cbc244fc3ed 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css @@ -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; +} + diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index d3b3856cef3..6892e304e96 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -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( 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> { + 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; + } +}