Skip to content

Conversation

@hediet
Copy link
Member

@hediet hediet commented Jan 23, 2026

Implements #271133

Copilot AI review requested due to automatic review settings January 23, 2026 22:25
@hediet hediet enabled auto-merge (squash) January 23, 2026 22:25
@hediet hediet self-assigned this Jan 23, 2026
@vs-code-engineering vs-code-engineering bot added this to the January 2026 milestone Jan 23, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR wires inline completions/inline edits to support “cross-file next edits” by tracking a stable reference to the target text model, updating the inline edit view logic, and extending the long-distance hint widget to show target file metadata.

Changes:

  • Introduces TextModelValueReference and threads it through the inline completions pipeline so suggestions and edits can safely reference other text models (including cross-file targets).
  • Adjusts the inline edits view, long-distance hint widget, and preview editor to distinguish cross-file edits (showing file icons/names, different arrows/labels, and suppressing in-editor display where appropriate).
  • Extends InlineCompletionsModel/InlineCompletionsSource to resolve cross-file targets via ITextModelService, seed completions into other editors, and refine controller behavior when multiple editors are active.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/view.css Adds styling so the long-distance hint widget can display a file icon next to the target filename for cross-file edits.
src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/longDistancePreviewEditor.ts Switches the preview editor to use TextModelValueReference and selects between the preview model and the original target model, enabling correct preview for cross-file edits (plus a now-outdated JSDoc on target).
src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/inlineEditsLongDistanceHint.ts Updates the long-distance hint widget to display either the symbol outline for same-file edits or the target filename and icon for cross-file edits, and tweaks arrows/labels accordingly.
src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewProducer.ts Changes diff computation to use the new originalTextRef transformer instead of constructing TextModelText directly.
src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsView.ts Threads TextModelValueReference through UI state, hides inline edit display when the target URI differs from the current model, and adjusts caching and long-distance hint logic for cross-file scenarios.
src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditWithChanges.ts Updates InlineEditWithChanges to hold a TextModelValueReference for original text, allowing consistent use in cross-file diffs and views.
src/vs/editor/contrib/inlineCompletions/browser/model/textModelValueReference.ts Introduces an immutable snapshot wrapper over ITextModel (extending AbstractText) that asserts on version changes and exposes URI/length/transformer helpers for editing logic.
src/vs/editor/contrib/inlineCompletions/browser/model/renameSymbolProcessor.ts Wraps rename-target models in TextModelValueReference so rename-based inline edits also participate in the new reference system.
src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem.ts Reworks inline suggestion items to carry a TextModelValueReference as their originalTextRef, replaces uri fields on actions with target references, and updates diff/reshape logic to work over AbstractText snapshots.
src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource.ts Resolves inline completion targets via ITextModelService, wraps them in TextModelValueReference, hooks model-ref disposal to InlineSuggestionIdentity.onDispose, adds a seedWithCompletion helper, and guards clear against disposed state.
src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts Uses originalTextRef to derive nextEditUri, adds a cross-file accept path that opens the target editor and transplants the completion, and exposes transplantCompletion to seed external items into this model.
src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.ts Narrows rejection propagation so only inactive controllers (no focus) get auto-cancelled when another editor’s inline completions are explicitly cancelled.
Comments suppressed due to low confidence (1)

src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts:1205

  • The new cross-file acceptance path (accept calling openCodeEditor and transplantCompletion) changes core behavior but has no corresponding tests in the existing inlineCompletions test suite (e.g. inlineCompletions.test.ts / inlineEdits.test.ts). Given how much logic is involved (model ref management, seeding _source via seedWithCompletion, view state, and focus/scroll behavior), please add tests that cover accepting a cross-file inline edit end-to-end and ensure the transplanted completion is shown and cleaned up correctly.
		try {
			let followUpTrigger = false;
			editor.pushUndoStop();

			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 = InlineCompletionsController.get(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;
				if (alternativeAction && action.alternativeAction) {
					followUpTrigger = true;
					const altCommand = action.alternativeAction.command;
					await this._commandService
						.executeCommand(altCommand.id, ...(altCommand.arguments || []))
						.then(undefined, onUnexpectedExternalError);
				} else if (action.snippetInfo) {
					const mainEdit = TextReplacement.delete(action.textReplacement.range);
					const additionalEdits = completion.additionalTextEdits.map(e => new TextReplacement(Range.lift(e.range), e.text ?? ''));
					const edit = TextEdit.fromParallelReplacementsUnsorted([mainEdit, ...additionalEdits]);
					editor.edit(edit, this._getMetadata(completion, this.textModel.getLanguageId()));

					editor.setPosition(action.snippetInfo.range.getStartPosition(), 'inlineCompletionAccept');
					SnippetController2.get(editor)?.insert(action.snippetInfo.snippet, { undoStopBefore: false });
				} else {
					const edits = state.edits;

					// The cursor should move to the end of the edit, not the end of the range provided by the extension
					// Inline Edit diffs (human readable) the suggestion from the extension so it already removes common suffix/prefix
					// Inline Completions does diff the suggestion so it may contain common suffix
					let minimalEdits = edits;
					if (state.kind === 'ghostText') {
						minimalEdits = removeTextReplacementCommonSuffixPrefix(edits, this.textModel);
					}
					const selections = getEndPositionsAfterApplying(minimalEdits).map(p => Selection.fromPositions(p));

					const additionalEdits = completion.additionalTextEdits.map(e => new TextReplacement(Range.lift(e.range), e.text ?? ''));
					const edit = TextEdit.fromParallelReplacementsUnsorted([...edits, ...additionalEdits]);

					editor.edit(edit, this._getMetadata(completion, this.textModel.getLanguageId()));

					if (completion.hint === undefined) {
						// do not move the cursor when the completion is displayed in a different location
						editor.setSelections(state.kind === 'inlineEdit' ? selections.slice(-1) : selections, 'inlineCompletionAccept');
					}

					if (state.kind === 'inlineEdit' && !this._accessibilityService.isMotionReduced()) {
						const editRanges = edit.getNewRanges();
						const dec = this._store.add(new FadeoutDecoration(editor, editRanges, () => {
							this._store.delete(dec);
						}));
					}
				}
			}

			this._onDidAccept.fire();

			// Reset before invoking the command, as the command might cause a follow up trigger (which we don't want to reset).
			this.stop();

			if (completion.command) {
				await this._commandService
					.executeCommand(completion.command.id, ...(completion.command.arguments || []))
					.then(undefined, onUnexpectedExternalError);
			}

			// TODO: how can we make alternative actions to retrigger?
			if (followUpTrigger) {
				this.trigger(undefined);
			}

			completion.reportEndOfLife({ kind: InlineCompletionEndOfLifeReasonKind.Accepted, alternativeAction });
		} finally {
			completion.removeRef();
			this._inAcceptFlow.set(true, undefined);
			this._lastAcceptedInlineCompletionInfo = { textModelVersionIdAfter: this.textModel.getVersionId(), inlineCompletion: completion };
		}
	}

	public async acceptNextWord(): Promise<void> {
		await this._acceptNext(this._editor, 'word', (pos, text) => {
			const langId = this.textModel.getLanguageIdAtPosition(pos.lineNumber, pos.column);
			const config = this._languageConfigurationService.getLanguageConfiguration(langId);
			const wordRegExp = new RegExp(config.wordDefinition.source, config.wordDefinition.flags.replace('g', ''));

			const m1 = text.match(wordRegExp);
			let acceptUntilIndexExclusive = 0;
			if (m1 && m1.index !== undefined) {
				if (m1.index === 0) {
					acceptUntilIndexExclusive = m1[0].length;
				} else {
					acceptUntilIndexExclusive = m1.index;
				}
			} else {
				acceptUntilIndexExclusive = text.length;
			}

			const wsRegExp = /\s+/g;
			const m2 = wsRegExp.exec(text);
			if (m2 && m2.index !== undefined) {
				if (m2.index + m2[0].length < acceptUntilIndexExclusive) {
					acceptUntilIndexExclusive = m2.index + m2[0].length;
				}
			}
			return acceptUntilIndexExclusive;
		}, PartialAcceptTriggerKind.Word);
	}

	public async acceptNextLine(): Promise<void> {
		await this._acceptNext(this._editor, 'line', (pos, text) => {
			const m = text.match(/\n/);
			if (m && m.index !== undefined) {
				return m.index + 1;
			}
			return text.length;
		}, PartialAcceptTriggerKind.Line);
	}

	private async _acceptNext(editor: ICodeEditor, type: 'word' | 'line', getAcceptUntilIndex: (position: Position, text: string) => number, kind: PartialAcceptTriggerKind): Promise<void> {
		if (editor.getModel() !== this.textModel) {
			throw new BugIndicatingError();
		}

		const state = this.inlineCompletionState.get();
		if (!state || state.primaryGhostText.isEmpty() || !state.inlineSuggestion) {
			return;
		}
		const ghostText = state.primaryGhostText;
		const completion = state.inlineSuggestion;

		if (completion.snippetInfo) {
			// not in WYSIWYG mode, partial commit might change completion, thus it is not supported
			await this.accept(editor);
			return;
		}

		const firstPart = ghostText.parts[0];
		const ghostTextPos = new Position(ghostText.lineNumber, firstPart.column);
		const ghostTextVal = firstPart.text;
		const acceptUntilIndexExclusive = getAcceptUntilIndex(ghostTextPos, ghostTextVal);
		if (acceptUntilIndexExclusive === ghostTextVal.length && ghostText.parts.length === 1) {
			this.accept(editor);
			return;
		}
		const partialGhostTextVal = ghostTextVal.substring(0, acceptUntilIndexExclusive);

		const positions = this._positions.get();
		const cursorPosition = positions[0];

		// Executing the edit might free the completion, so we have to hold a reference on it.
		completion.addRef();
		try {
			this._isAcceptingPartially = true;
			try {
				editor.pushUndoStop();
				const replaceRange = Range.fromPositions(cursorPosition, ghostTextPos);
				const newText = editor.getModel()!.getValueInRange(replaceRange) + partialGhostTextVal;
				const primaryEdit = new TextReplacement(replaceRange, newText);
				const edits = [primaryEdit, ...getSecondaryEdits(this.textModel, positions, primaryEdit)].filter(isDefined);
				const selections = getEndPositionsAfterApplying(edits).map(p => Selection.fromPositions(p));

				editor.edit(TextEdit.fromParallelReplacementsUnsorted(edits), this._getMetadata(completion, type));
				editor.setSelections(selections, 'inlineCompletionPartialAccept');
				editor.revealPositionInCenterIfOutsideViewport(editor.getPosition()!, ScrollType.Smooth);
			} finally {
				this._isAcceptingPartially = false;
			}

			const acceptedRange = Range.fromPositions(completion.editRange.getStartPosition(), TextLength.ofText(partialGhostTextVal).addToPosition(ghostTextPos));
			// This assumes that the inline completion and the model use the same EOL style.
			const text = editor.getModel()!.getValueInRange(acceptedRange, EndOfLinePreference.LF);
			const acceptedLength = text.length;
			completion.reportPartialAccept(
				acceptedLength,
				{ kind, acceptedLength: acceptedLength },
				{ characters: acceptUntilIndexExclusive, ratio: acceptUntilIndexExclusive / ghostTextVal.length, count: 1 }
			);

		} finally {
			completion.removeRef();
		}
	}

	public handleSuggestAccepted(item: SuggestItemInfo) {
		const itemEdit = singleTextRemoveCommonPrefix(item.getSingleTextEdit(), this.textModel);
		const augmentedCompletion = this._computeAugmentation(itemEdit, undefined);
		if (!augmentedCompletion) { return; }

		// This assumes that the inline completion and the model use the same EOL style.
		const alreadyAcceptedLength = this.textModel.getValueInRange(augmentedCompletion.completion.editRange, EndOfLinePreference.LF).length;
		const acceptedLength = alreadyAcceptedLength + itemEdit.text.length;

		augmentedCompletion.completion.reportPartialAccept(itemEdit.text.length, {
			kind: PartialAcceptTriggerKind.Suggest,
			acceptedLength,
		}, {
			characters: itemEdit.text.length,
			count: 1,
			ratio: 1
		});
	}

	public extractReproSample(): Repro {
		const value = this.textModel.getValue();
		const item = this.state.get()?.inlineSuggestion;
		return {
			documentValue: value,
			inlineCompletion: item?.getSourceCompletion(),
		};
	}

	private readonly _jumpedToId = observableValue<undefined | string>(this, undefined);
	private readonly _inAcceptFlow = observableValue(this, false);
	public readonly inAcceptFlow: IObservable<boolean> = this._inAcceptFlow;

	public jump(): void {
		const s = this.inlineEditState.get();
		if (!s) { return; }

		const suggestion = s.inlineSuggestion;

		if (!suggestion.originalTextRef.targets(this.textModel)) {
			this.accept(this._editor);
			return;
		}


		suggestion.addRef();
		try {
			transaction(tx => {
				if (suggestion.action?.kind === 'jumpTo') {
					this.stop(undefined, tx);
					suggestion.reportEndOfLife({ kind: InlineCompletionEndOfLifeReasonKind.Accepted, alternativeAction: false });
				}

				this._jumpedToId.set(s.inlineSuggestion.semanticId, tx);
				this.dontRefetchSignal.trigger(tx);
				const targetRange = s.inlineSuggestion.targetRange;
				const targetPosition = targetRange.getStartPosition();
				this._editor.setPosition(targetPosition, 'inlineCompletions.jump');

				// TODO: consider using view information to reveal it
				const isSingleLineChange = targetRange.isSingleLine() && (s.inlineSuggestion.hint || (s.inlineSuggestion.action?.kind === 'edit' && !s.inlineSuggestion.action.textReplacement.text.includes('\n')));
				if (isSingleLineChange || s.inlineSuggestion.action?.kind === 'jumpTo') {
					this._editor.revealPosition(targetPosition, ScrollType.Smooth);
				} else {
					const revealRange = new Range(targetRange.startLineNumber - 1, 1, targetRange.endLineNumber + 1, 1);
					this._editor.revealRange(revealRange, ScrollType.Smooth);
				}

				s.inlineSuggestion.identity.setJumpTo(tx);

				this._editor.focus();
			});
		} finally {
			suggestion.removeRef();
		}
	}

	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 {

* Used for cross-file inline edits.
*/
public transplantCompletion(item: InlineSuggestionItem): void {
item.addRef();
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transplantCompletion calls item.addRef() and then passes the same item into seedWithCompletion, whose InlineCompletionsState constructor also calls addRef() on each item. The corresponding removeRef() calls (in accept() and state disposal) don't fully balance this extra addRef, so InlineSuggestionIdentity.onDispose may never fire and the runOnChange subscription in InlineCompletionsSource will not dispose the associated ITextModelService model reference, leading to a memory/resource leak for cross-file inline edits. Consider removing the explicit item.addRef() here and relying on InlineCompletionsState to manage the item lifetime, or otherwise ensuring every addRef() has a matching removeRef() when the transplant is finished.

This issue also appears in the following locations of the same file:

  • line 930
Suggested change
item.addRef();

Copilot uses AI. Check for mistakes.
Comment on lines +35 to +36
* The URI of the file the edit targets.
* When undefined (or same as the editor's model URI), the edit targets the current file.
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc on ILongDistancePreviewProps.target still describes it as "The URI of the file the edit targets" and talks about being undefined, but the type is now TextModelValueReference and all call sites always provide a value. This mismatch can confuse future maintainers; please update the comment to reflect that target is a snapshot reference to the target text model (and clarify its nullability) rather than just a URI.

Suggested change
* The URI of the file the edit targets.
* When undefined (or same as the editor's model URI), the edit targets the current file.
* Snapshot reference to the text model that the edit targets.
* This reference is always provided and is never undefined.

Copilot uses AI. Check for mistakes.
@bpasero
Copy link
Member

bpasero commented Jan 24, 2026

CI is red

@hediet hediet modified the milestones: January 2026, February 2026 Jan 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants