Merge pull request #279680 from microsoft/benibenj/young-jaguar

Improve rename handling in inline edits
This commit is contained in:
Benjamin Christopher Simmonds
2025-11-27 00:09:42 +01:00
committed by GitHub
10 changed files with 135 additions and 54 deletions
@@ -51,6 +51,7 @@ import { StringReplacement } from '../../../../common/core/edits/stringEdit.js';
import { OffsetRange } from '../../../../common/core/ranges/offsetRange.js';
import { URI } from '../../../../../base/common/uri.js';
import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js';
import { ModifierKeyEmitter } from '../../../../../base/browser/dom.js';
export class InlineCompletionsModel extends Disposable {
private readonly _source;
@@ -630,7 +631,7 @@ export class InlineCompletionsModel extends Disposable {
return undefined;
}
const cursorAtInlineEdit = this.primaryPosition.map(cursorPos => LineRange.fromRangeInclusive(inlineEditResult.targetRange).addMargin(1, 1).contains(cursorPos.lineNumber));
const stringEdit = inlineEditResult.action?.kind === 'edit' ? inlineEditResult.action.stringEdit : undefined;
const stringEdit = inlineEditResult.action?.kind === 'edit' || inlineEditResult.action?.kind === 'rename' ? 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') &&
@@ -904,9 +905,13 @@ export class InlineCompletionsModel extends Disposable {
editor.pushUndoStop();
if (isNextEditUri) {
// Do nothing
} else if (completion.action?.kind === 'edit') {
} else if (completion.action?.kind === 'edit' || completion.action?.kind === 'rename') {
const action = completion.action;
if (action.snippetInfo) {
if (action.kind === 'rename' && !ModifierKeyEmitter.getInstance().keyStatus.altKey) {
await this._commandService
.executeCommand(action.command.id, ...(action.command.arguments || []))
.then(undefined, onUnexpectedExternalError);
} else if (action.kind === 'edit' && 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]);
@@ -950,12 +955,6 @@ export class InlineCompletionsModel extends Disposable {
// 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.renameCommand) {
await this._commandService
.executeCommand(completion.renameCommand.id, ...(completion.renameCommand.arguments || []))
.then(undefined, onUnexpectedExternalError);
}
if (completion.command) {
await this._commandService
.executeCommand(completion.command.id, ...(completion.command.arguments || []))
@@ -273,7 +273,7 @@ export class InlineCompletionsSource extends Disposable {
return this._renameProcessor.proposeRenameRefactoring(this._textModel, s);
}));
providerSuggestions.forEach(s => s.addPerformanceMarker('renameProcessed'));
suggestions.forEach(s => s.addPerformanceMarker('renameProcessed'));
providerResult.cancelAndDispose({ kind: 'lostRace' });
@@ -25,7 +25,7 @@ import { EndOfLinePreference, ITextModel } from '../../../../common/model.js';
import { TextModelText } from '../../../../common/model/textModelText.js';
import { InlineCompletionViewData, InlineCompletionViewKind } from '../view/inlineEdits/inlineEditsViewInterface.js';
import { computeEditKind, InlineSuggestionEditKind } from './editKind.js';
import { IInlineSuggestDataActionEdit, InlineSuggestData, InlineSuggestionList, PartialAcceptance, RenameInfo, SnippetInfo } from './provideInlineCompletions.js';
import { IInlineSuggestDataActionEdit, IInlineSuggestDataActionRename, InlineSuggestData, InlineSuggestionList, PartialAcceptance, RenameInfo, SnippetInfo } from './provideInlineCompletions.js';
import { singleTextRemoveCommonPrefix } from './singleTextEditHelpers.js';
export type InlineSuggestionItem = InlineEditItem | InlineCompletionItem;
@@ -43,7 +43,7 @@ export namespace InlineSuggestionItem {
}
}
export type InlineSuggestionAction = IInlineSuggestionActionEdit | IInlineSuggestionActionJumpTo;
export type InlineSuggestionAction = IInlineSuggestionActionEdit | IInlineSuggestionActionJumpTo | IInlineSuggestionActionRename;
export interface IInlineSuggestionActionEdit {
kind: 'edit';
@@ -60,6 +60,19 @@ export interface IInlineSuggestionActionJumpTo {
uri: URI | undefined;
}
export interface IInlineSuggestionActionRename {
kind: 'rename';
textReplacement: TextReplacement;
stringEdit: StringEdit;
uri: URI | undefined;
command: Command;
}
function hashInlineSuggestionAction(action: InlineSuggestionAction | undefined): string {
const obj = action?.kind === 'rename' ? { ...action, command: action.command.id } : action;
return JSON.stringify(obj);
}
abstract class InlineSuggestionItemBase {
constructor(
protected readonly _data: InlineSuggestData,
@@ -83,7 +96,7 @@ abstract class InlineSuggestionItemBase {
if (this.hint) {
return this.hint.range;
}
if (this.action?.kind === 'edit') {
if (this.action?.kind === 'edit' || this.action?.kind === 'rename') {
return this.action.textReplacement.range;
} else if (this.action?.kind === 'jumpTo') {
return Range.fromPositions(this.action.position);
@@ -95,11 +108,10 @@ abstract class InlineSuggestionItemBase {
public get gutterMenuLinkAction(): Command | undefined { return this._sourceInlineCompletion.gutterMenuLinkAction; }
public get command(): Command | undefined { return this._sourceInlineCompletion.command; }
public get supportsRename(): boolean { return this._data.supportsRename; }
public get renameCommand(): Command | undefined { return this._data.renameCommand; }
public get warning(): InlineCompletionWarning | undefined { return this._sourceInlineCompletion.warning; }
public get showInlineEditMenu(): boolean { return !!this._sourceInlineCompletion.showInlineEditMenu; }
public get hash(): string {
return JSON.stringify(this.action);
return hashInlineSuggestionAction(this.action);
}
/** @deprecated */
public get shownCommand(): Command | undefined { return this._sourceInlineCompletion.shownCommand; }
@@ -133,7 +145,7 @@ abstract class InlineSuggestionItemBase {
}
public reportInlineEditShown(commandService: ICommandService, viewKind: InlineCompletionViewKind, viewData: InlineCompletionViewData, model: ITextModel) {
const insertText = this.action?.kind === 'edit' ? this.action.textReplacement.text : ''; // TODO@hediet support insertText === undefined
const insertText = this.action?.kind === 'edit' || this.action?.kind === 'rename' ? this.action.textReplacement.text : ''; // TODO@hediet support insertText === undefined
this._data.reportInlineEditShown(commandService, insertText, viewKind, viewData, this.computeEditKind(model));
}
@@ -168,8 +180,8 @@ abstract class InlineSuggestionItemBase {
this._data.setRenameProcessingInfo(info);
}
public withRename(command: Command, hint: InlineSuggestHint): InlineSuggestData {
return this._data.withRename(command, hint);
public withRename(renameAction: IInlineSuggestDataActionRename): InlineSuggestData {
return this._data.withRename(renameAction);
}
public addPerformanceMarker(marker: string): void {
@@ -434,6 +446,8 @@ export class InlineEditItem extends InlineSuggestionItemBase {
offset: textModel.getOffsetAt(data.action.position),
uri: data.action.uri,
};
} else if (data.action?.kind === 'rename') {
action = data.action;
} else {
action = undefined;
if (!data.hint) {
@@ -501,7 +515,7 @@ export class InlineEditItem extends InlineSuggestionItemBase {
let inlineEditModelVersion = this._inlineEditModelVersion;
let newAction: InlineSuggestionAction | undefined;
if (this.action?.kind === 'edit') {
if (this.action?.kind === 'edit') { // TODO What about rename?
edits = edits.map(innerEdit => innerEdit.applyTextModelChanges(textModelChanges));
if (edits.some(edit => edit.edit === undefined)) {
@@ -573,7 +587,7 @@ export class InlineEditItem extends InlineSuggestionItemBase {
}
override computeEditKind(model: ITextModel): InlineSuggestionEditKind | undefined {
const edit = this.action?.kind === 'edit' ? this.action.stringEdit : undefined;
const edit = this.action?.kind === 'edit' || this.action?.kind === 'rename' ? this.action.stringEdit : undefined;
if (!edit) {
return undefined;
}
@@ -11,7 +11,7 @@ import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js
import { prefixedUuid } from '../../../../../base/common/uuid.js';
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
import { ISingleEditOperation } from '../../../../common/core/editOperation.js';
import { StringReplacement } from '../../../../common/core/edits/stringEdit.js';
import { StringEdit, StringReplacement } from '../../../../common/core/edits/stringEdit.js';
import { OffsetRange } from '../../../../common/core/ranges/offsetRange.js';
import { Position } from '../../../../common/core/position.js';
import { Range } from '../../../../common/core/range.js';
@@ -270,7 +270,6 @@ function toInlineSuggestData(
context,
inlineCompletion.isInlineEdit ?? false,
inlineCompletion.supportsRename ?? false,
undefined,
requestInfo,
providerRequestInfo,
inlineCompletion.correlationId,
@@ -311,7 +310,7 @@ export type InlineSuggestViewData = {
viewKind?: InlineCompletionViewKind;
};
export type IInlineSuggestDataAction = IInlineSuggestDataActionEdit | IInlineSuggestDataActionJumpTo;
export type IInlineSuggestDataAction = IInlineSuggestDataActionEdit | IInlineSuggestDataActionJumpTo | IInlineSuggestDataActionRename;
export interface IInlineSuggestDataActionEdit {
kind: 'edit';
@@ -327,6 +326,14 @@ export interface IInlineSuggestDataActionJumpTo {
uri: URI | undefined;
}
export interface IInlineSuggestDataActionRename {
kind: 'rename';
textReplacement: TextReplacement;
stringEdit: StringEdit;
uri: URI | undefined;
command: Command;
}
export class InlineSuggestData {
private _didShow = false;
private _timeUntilShown: number | undefined = undefined;
@@ -354,7 +361,6 @@ export class InlineSuggestData {
public readonly context: InlineCompletionContext,
public readonly isInlineEdit: boolean,
public readonly supportsRename: boolean,
public readonly renameCommand: Command | undefined,
private readonly _requestInfo: InlineSuggestRequestInfo,
private readonly _providerRequestInfo: InlineSuggestProviderRequestInfo,
private readonly _correlationId: string | undefined,
@@ -517,17 +523,16 @@ export class InlineSuggestData {
this._renameInfo = info;
}
public withRename(command: Command, hint: IInlineCompletionHint): InlineSuggestData {
public withRename(renameAction: IInlineSuggestDataActionRename): InlineSuggestData {
return new InlineSuggestData(
undefined,
hint,
renameAction,
this.hint,
this.additionalTextEdits,
this.sourceInlineCompletion,
this.source,
this.context,
this.isInlineEdit,
this.supportsRename,
command,
this._requestInfo,
this._providerRequestInfo,
this._correlationId,
@@ -14,14 +14,15 @@ import { TextEdit } from '../../../../common/core/edits/textEdit.js';
import { Position } from '../../../../common/core/position.js';
import { Range } from '../../../../common/core/range.js';
import { StandardTokenType } from '../../../../common/encodedTokenAttributes.js';
import { Command, InlineCompletionHintStyle } from '../../../../common/languages.js';
import { Command } from '../../../../common/languages.js';
import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js';
import { ITextModel } from '../../../../common/model.js';
import { ILanguageFeaturesService } from '../../../../common/services/languageFeatures.js';
import { EditSources, TextModelEditSource } from '../../../../common/textModelEditSource.js';
import { hasProvider, prepareRename, rename } from '../../../rename/browser/rename.js';
import { renameSymbolCommandId } from '../controller/commandIds.js';
import { InlineSuggestHint, InlineSuggestionItem } from './inlineSuggestionItem.js';
import { InlineSuggestionItem } from './inlineSuggestionItem.js';
import { IInlineSuggestDataActionRename } from './provideInlineCompletions.js';
export type RenameEdits = {
renames: { edits: TextEdit[]; position: Position; oldName: string; newName: string };
@@ -258,14 +259,19 @@ export class RenameSymbolProcessor extends Disposable {
providerId: suggestItem.source.provider.providerId,
languageId: textModel.getLanguageId(),
});
const hintRange = edits.renames.edits[0].replacements[0].range;
const label = localize('renameSymbol', "Rename '{0}' to '{1}'", oldName, newName);
const command: Command = {
id: renameSymbolCommandId,
title: label,
arguments: [textModel, position, newName, source],
};
const hint = InlineSuggestHint.create({ range: hintRange, content: label, style: InlineCompletionHintStyle.Code });
return InlineSuggestionItem.create(suggestItem.withRename(command, hint), textModel);
const renameAction: IInlineSuggestDataActionRename = {
kind: 'rename',
textReplacement: edit,
stringEdit: suggestItem.action.stringEdit,
command,
uri: textModel.uri
};
return InlineSuggestionItem.create(suggestItem.withRename(renameAction), textModel);
}
}
@@ -16,7 +16,7 @@ export class InlineEditWithChanges {
public get lineEdit(): LineReplacement {
if (this.action?.kind === 'jumpTo') {
return new LineReplacement(LineRange.ofLength(this.action.position.lineNumber, 0), []);
} else if (this.action?.kind === 'edit') {
} else if (this.action?.kind === 'edit' || this.action?.kind === 'rename') {
return LineReplacement.fromSingleTextEdit(this.edit!.toReplacement(this.originalText), this.originalText);
}
@@ -109,11 +109,11 @@ export class InlineEditsView extends Disposable {
this._inlineCollapsedView = this._register(this._instantiationService.createInstance(InlineEditsCollapsedView,
this._editor,
this._model.map((m, reader) => this._uiState.read(reader)?.state?.kind === 'collapsed' ? m?.inlineEdit : undefined)
this._model.map((m, reader) => this._uiState.read(reader)?.state?.kind === InlineCompletionViewKind.Collapsed ? m?.inlineEdit : undefined)
));
this._customView = this._register(this._instantiationService.createInstance(InlineEditsCustomView,
this._editor,
this._model.map((m, reader) => this._uiState.read(reader)?.state?.kind === 'custom' ? m?.displayLocation : undefined),
this._model.map((m, reader) => this._uiState.read(reader)?.state?.kind === InlineCompletionViewKind.Custom ? m?.displayLocation : undefined),
this._tabAction,
));
@@ -164,10 +164,10 @@ export class InlineEditsView extends Disposable {
equalsFn: itemsEquals(itemEquals())
}, reader => {
const s = this._uiState.read(reader);
return s?.state?.kind === 'wordReplacements' ? s.state.replacements : [];
return s?.state?.kind === InlineCompletionViewKind.WordReplacements ? s.state.replacements : [];
});
this._wordReplacementViews = mapObservableArrayCached(this, wordReplacements, (e, store) => {
return store.add(this._instantiationService.createInstance(InlineEditsWordReplacementView, this._editorObs, e, this._tabAction));
return store.add(this._instantiationService.createInstance(InlineEditsWordReplacementView, this._editorObs, e, this._model.get()?.inlineEdit.inlineCompletion.action?.kind === 'rename', this._tabAction));
});
this._lineReplacementView = this._register(this._instantiationService.createInstance(InlineEditsLineReplacementView,
this._editorObs,
@@ -426,6 +426,10 @@ export class InlineEditsView extends Disposable {
return this._previousView!.view;
}
if (model.inlineEdit.inlineCompletion.action?.kind === 'rename') {
return InlineCompletionViewKind.WordReplacements;
}
const uri = model.inlineEdit.inlineCompletion.action?.kind === 'edit' ? model.inlineEdit.inlineCompletion.action.uri : undefined;
if (uri !== undefined) {
return InlineCompletionViewKind.Custom;
@@ -35,7 +35,7 @@ export class InlineEditsViewAndDiffProducer extends Disposable { // TODO: This c
let diffEdits: TextEdit | undefined;
if (action?.kind === 'edit') {
if (action?.kind === 'edit' || action?.kind === 'rename') {
const editOffset = action.stringEdit;
const edits = editOffset.replacements.map(e => {
@@ -3,12 +3,14 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { getWindow, n, ObserverNodeWithElement } from '../../../../../../../base/browser/dom.js';
import { getWindow, ModifierKeyEmitter, n, ObserverNodeWithElement } from '../../../../../../../base/browser/dom.js';
import { IMouseEvent, StandardMouseEvent } from '../../../../../../../base/browser/mouseEvent.js';
import { renderIcon } from '../../../../../../../base/browser/ui/iconLabel/iconLabels.js';
import { Codicon } from '../../../../../../../base/common/codicons.js';
import { Emitter } from '../../../../../../../base/common/event.js';
import { Disposable } from '../../../../../../../base/common/lifecycle.js';
import { constObservable, derived, IObservable, observableValue } from '../../../../../../../base/common/observable.js';
import { editorBackground, editorHoverForeground } from '../../../../../../../platform/theme/common/colorRegistry.js';
import { constObservable, derived, IObservable, observableFromEvent, observableValue } from '../../../../../../../base/common/observable.js';
import { editorBackground, editorHoverBorder, editorHoverForeground } from '../../../../../../../platform/theme/common/colorRegistry.js';
import { asCssVariable } from '../../../../../../../platform/theme/common/colorUtils.js';
import { ObservableCodeEditor } from '../../../../../../browser/observableCodeEditor.js';
import { LineSource, renderLines, RenderOptions } from '../../../../../../browser/widget/diffEditor/components/diffEditorViewZones/renderLines.js';
@@ -48,6 +50,7 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin
private readonly _editor: ObservableCodeEditor,
/** Must be single-line in both sides */
private readonly _edit: TextReplacement,
private readonly _rename: boolean,
protected readonly _tabAction: IObservable<InlineEditTabAction>,
@ILanguageService private readonly _languageService: ILanguageService,
) {
@@ -76,6 +79,7 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin
this._line.style.width = `${res.minWidthInPx}px`;
});
const modifiedLineHeight = this._editor.observeLineHeightForPosition(this._edit.range.getStartPosition());
const altPressed = observableFromEvent(this, ModifierKeyEmitter.getInstance().event, keyStatus => keyStatus?.altKey ?? ModifierKeyEmitter.getInstance().keyStatus.altKey);
this._layout = derived(this, reader => {
this._renderTextEffect.read(reader);
const widgetStart = this._start.read(reader);
@@ -94,15 +98,26 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin
const modifiedTopOffset = 4;
const modifiedOffset = new Point(modifiedLeftOffset, modifiedTopOffset);
const originalLine = Rect.fromPoints(widgetStart, widgetEnd).withHeight(lineHeight).translateX(-scrollLeft);
const modifiedLine = Rect.fromPointSize(originalLine.getLeftBottom().add(modifiedOffset), new Point(this._edit.text.length * w, originalLine.height));
let label = undefined;
if (this._rename) { // TODO: make this customizable and not rename specific
if (altPressed.read(reader)) {
label = { content: 'Edit', icon: Codicon.edit };
} else {
label = { content: 'Rename', icon: Codicon.replaceAll };
}
}
const originalLine = Rect.fromPoints(widgetStart, widgetEnd).withHeight(lineHeight).translateX(-scrollLeft);
const codeLine = Rect.fromPointSize(originalLine.getLeftBottom().add(modifiedOffset), new Point(this._edit.text.length * w, originalLine.height));
const modifiedLine = codeLine.withWidth(codeLine.width + (label ? label.content.length * w + 8 + 4 + 12 : 0));
const lowerBackground = modifiedLine.withLeft(originalLine.left);
// debugView(debugLogRects({ lowerBackground }, this._editor.editor.getContainerDomNode()), reader);
return {
label,
originalLine,
codeLine,
modifiedLine,
lowerBackground,
lineHeight,
@@ -157,23 +172,56 @@ export class InlineEditsWordReplacementView extends Disposable implements IInlin
style: {
position: 'absolute',
...rectToProps(reader => layout.read(reader).modifiedLine.withMargin(BORDER_WIDTH, 2 * BORDER_WIDTH)),
fontFamily: this._editor.getOption(EditorOption.fontFamily),
fontSize: this._editor.getOption(EditorOption.fontSize),
fontWeight: this._editor.getOption(EditorOption.fontWeight),
width: undefined,
pointerEvents: 'none',
boxSizing: 'border-box',
borderRadius: '4px',
border: `${BORDER_WIDTH}px solid ${modifiedBorderColor}`,
background: asCssVariable(modifiedChangedTextOverlayColor),
background: asCssVariable(editorBackground),
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
justifyContent: 'left',
outline: `2px solid ${asCssVariable(editorBackground)}`,
}
}, [this._line]),
}, [
n.div({
style: {
fontFamily: this._editor.getOption(EditorOption.fontFamily),
fontSize: this._editor.getOption(EditorOption.fontSize),
fontWeight: this._editor.getOption(EditorOption.fontWeight),
width: rectToProps(reader => layout.read(reader).codeLine.withMargin(BORDER_WIDTH, 2 * BORDER_WIDTH)).width,
borderRadius: layout.map(l => l.label ? '4px 0 0 4px' : '4px'),
border: `${BORDER_WIDTH}px solid ${modifiedBorderColor}`,
boxSizing: 'border-box',
padding: `${BORDER_WIDTH}px`,
background: asCssVariable(modifiedChangedTextOverlayColor),
display: 'flex',
justifyContent: 'left',
alignItems: 'center',
}
}, [this._line]),
derived(this, reader => {
const label = layout.read(reader).label;
if (!label) {
return undefined;
}
return n.div({
style: {
fontFamily: this._editor.getOption(EditorOption.fontFamily),
fontSize: this._editor.getOption(EditorOption.fontSize),
fontWeight: this._editor.getOption(EditorOption.fontWeight),
borderRadius: '0 4px 4px 0',
border: `${BORDER_WIDTH}px solid ${asCssVariable(editorHoverBorder)}`,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '0 4px',
},
class: 'inline-edit-rename-label',
}, [renderIcon(label.icon), label.content]);
})
]),
n.div({
style: {
position: 'absolute',
@@ -243,7 +243,7 @@
}
}
.go-to-label::before {
.inline-edits-long-distance-hint-widget .go-to-label::before {
content: '';
position: absolute;
left: -12px;
@@ -252,3 +252,8 @@
height: 100%;
background: linear-gradient(to left, var(--vscode-editorWidget-background) 0, transparent 12px);
}
.inline-edit-rename-label .codicon {
font-size: 12px !important;
padding-right: 4px;
}