Path: blob/main/src/vs/workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.ts
5263 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';6import { autorun, derived, IObservable, observableValue } from '../../../../base/common/observable.js';7import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';8import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js';9import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';10import { IRenameSymbolTrackerService, type ITrackedWord } from '../../../../editor/browser/services/renameSymbolTrackerService.js';11import { Position } from '../../../../editor/common/core/position.js';12import { Range } from '../../../../editor/common/core/range.js';13import { StandardTokenType } from '../../../../editor/common/encodedTokenAttributes.js';14import { ITextModel } from '../../../../editor/common/model.js';15import { IModelContentChangedEvent } from '../../../../editor/common/textModelEvents.js';16import { TextModelEditSource } from '../../../../editor/common/textModelEditSource.js';17import { IModelService } from '../../../../editor/common/services/model.js';18import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';1920/**21* Checks if a model content change event was caused only by typing or pasting.22* Returns false for AI edits, refactorings, undo/redo, etc.23*/24function isUserEdit(event: IModelContentChangedEvent): boolean {25if (event.isUndoing || event.isRedoing || event.isFlush) {26return false;27}2829for (const source of event.detailedReasons) {30if (!isUserEditSource(source)) {31return false;32}33}3435return event.detailedReasons.length > 0;36}3738const userEditKinds = new Set(['type', 'paste', 'cut', 'executeCommands', 'executeCommand', 'compositionType', 'compositionEnd']);39function isUserEditSource(source: TextModelEditSource): boolean {40const metadata = source.metadata;41if (metadata.source !== 'cursor') {42return false;43}44const kind = metadata.kind;45return userEditKinds.has(kind);46}4748type WordState = {49word: string;50range: Range;51position: Position;52};5354/**55* Tracks symbol edits for a single ITextModel.56*57* Receives cursor position updates from external sources (e.g., focused code editors).58* Only tracks edits done by typing or paste. Resets when:59* - A non-typing/paste edit occurs (AI, refactoring, undo/redo, etc.)60*/61class ModelSymbolRenameTracker extends Disposable {62private readonly _trackedWord = observableValue<ITrackedWord | undefined>(this, undefined);63public readonly trackedWord: IObservable<ITrackedWord | undefined> = this._trackedWord;6465private _capturedWord: WordState | undefined = undefined;66private _lastWordBeforeEdit: WordState | undefined = undefined;67private _pendingContentChange: boolean = false;68private _lastCursorPosition: Position | undefined = undefined;6970constructor(71private readonly _model: ITextModel72) {73super();7475// Listen to content changes - only reset on non-typing/paste edits76this._register(this._model.onDidChangeContent(e => {77if (!isUserEdit(e)) {78// Non-user edit has occurred - reset rename tracking at79// the current cursor position (if any)80const position = this._lastCursorPosition;81this.reset();82if (position !== undefined) {83this.updateCursorPosition(position);84}85return;86}87// Valid typing/paste edit - mark that content changed, cursor update will handle tracking88this._pendingContentChange = true;89}));90}9192/**93* Called by the service when the cursor position changes in an editor showing this model.94* Updates tracking based on the word under cursor and whether content has changed.95*/96public updateCursorPosition(position: Position): void {97this._lastCursorPosition = position;98const wordAtPosition = this._model.getWordAtPosition(position);99if (!wordAtPosition) {100// Not on a word - just clear lastWordBeforeEdit101this._lastWordBeforeEdit = undefined;102this._pendingContentChange = false;103return;104}105106// Check if the position is in a comment107if (this._isPositionInComment(position)) {108this._lastWordBeforeEdit = undefined;109this._pendingContentChange = false;110return;111}112113const currentWord: WordState = {114word: wordAtPosition.word,115range: new Range(116position.lineNumber,117wordAtPosition.startColumn,118position.lineNumber,119wordAtPosition.endColumn120),121position122};123124const contentChanged = this._pendingContentChange;125this._pendingContentChange = false;126127if (!contentChanged) {128// Just cursor movement - remember this word for later129this._lastWordBeforeEdit = currentWord;130return;131}132133// Content changed - update tracking134if (!this._capturedWord) {135// First edit on a word - use the word from before the edit as original136const originalWord = this._lastWordBeforeEdit ?? currentWord;137this._capturedWord = { ...originalWord };138this._trackedWord.set({139model: this._model,140originalWord: originalWord.word,141originalPosition: originalWord.position,142originalRange: originalWord.range,143currentWord: currentWord.word,144currentRange: currentWord.range,145}, undefined);146this._lastWordBeforeEdit = currentWord;147return;148}149150const capturedWord = this._capturedWord;151// Check if we're still on the same word (by position overlap or adjacency)152const isOnSameWord = this._rangesOverlap(capturedWord.range, currentWord.range) ||153this._isAdjacent(capturedWord.range, currentWord.range);154155if (isOnSameWord) {156// Word has been edited - update the tracked word157this._trackedWord.set({158model: this._model,159originalWord: capturedWord.word,160originalPosition: capturedWord.position,161originalRange: capturedWord.range,162currentWord: currentWord.word,163currentRange: currentWord.range,164}, undefined);165} else {166// User started typing in a different word - use the word from before the edit as original167const originalWord = this._lastWordBeforeEdit ?? currentWord;168this._capturedWord = { ...originalWord };169this._trackedWord.set({170model: this._model,171originalWord: originalWord.word,172originalPosition: originalWord.position,173originalRange: originalWord.range,174currentWord: currentWord.word,175currentRange: currentWord.range,176}, undefined);177}178// Update lastWordBeforeEdit for the next iteration179this._lastWordBeforeEdit = currentWord;180}181182private reset(): void {183this._trackedWord.set(undefined, undefined);184this._capturedWord = undefined;185this._lastWordBeforeEdit = undefined;186this._pendingContentChange = false;187this._lastCursorPosition = undefined;188}189190private _isPositionInComment(position: Position): boolean {191this._model.tokenization.tokenizeIfCheap(position.lineNumber);192const tokens = this._model.tokenization.getLineTokens(position.lineNumber);193const tokenIndex = tokens.findTokenIndexAtOffset(position.column - 1);194const tokenType = tokens.getStandardTokenType(tokenIndex);195return tokenType === StandardTokenType.Comment;196}197198private _rangesOverlap(a: Range, b: Range): boolean {199if (a.startLineNumber !== b.startLineNumber) {200return false;201}202return !(a.endColumn < b.startColumn || b.endColumn < a.startColumn);203}204205private _isAdjacent(a: Range, b: Range): boolean {206if (a.startLineNumber !== b.startLineNumber) {207return false;208}209return a.endColumn === b.startColumn || b.endColumn === a.startColumn;210}211}212213class RenameSymbolTrackerService extends Disposable implements IRenameSymbolTrackerService {214public _serviceBrand: undefined;215216private readonly _modelTrackers = new Map<ITextModel, ModelSymbolRenameTracker>();217private readonly _editorFocusTrackingDisposables = new Map<ICodeEditor, IDisposable>();218219private readonly _focusedModelTracker = observableValue<ModelSymbolRenameTracker | undefined>(this, undefined);220221public readonly trackedWord: IObservable<ITrackedWord | undefined> = derived(this, reader => {222const tracker = this._focusedModelTracker.read(reader);223return tracker?.trackedWord.read(reader);224});225226constructor(227@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,228@IModelService private readonly _modelService: IModelService229) {230super();231232// Setup tracking for existing editors233for (const editor of this._codeEditorService.listCodeEditors()) {234this._setupEditorTracking(editor);235}236237// Track editor additions238this._register(this._codeEditorService.onCodeEditorAdd(editor => {239this._setupEditorTracking(editor);240}));241242// Clean up editor focus tracking when editors are removed243this._register(this._codeEditorService.onCodeEditorRemove(editor => {244const focusDisposable = this._editorFocusTrackingDisposables.get(editor);245if (focusDisposable) {246focusDisposable.dispose();247this._editorFocusTrackingDisposables.delete(editor);248}249}));250251// Clean up model trackers when models are removed252this._register(this._modelService.onModelRemoved(model => {253const tracker = this._modelTrackers.get(model);254if (tracker) {255tracker.dispose();256this._modelTrackers.delete(model);257}258}));259}260261private _setupEditorTracking(editor: ICodeEditor): void {262if (editor.isSimpleWidget) {263return;264}265266// Setup focus and cursor tracking267if (!this._editorFocusTrackingDisposables.has(editor)) {268const obsEditor = observableCodeEditor(editor);269270const focusDisposable = autorun(reader => {271/** @description track focused editor and forward cursor to model tracker */272const isFocused = obsEditor.isFocused.read(reader);273const model = obsEditor.model.read(reader);274const cursorPosition = obsEditor.cursorPosition.read(reader);275276if (!isFocused || !model) {277return;278}279280// Ensure we have a tracker for this model281let tracker = this._modelTrackers.get(model);282if (!tracker) {283tracker = new ModelSymbolRenameTracker(model);284this._modelTrackers.set(model, tracker);285}286287// Update the focused tracker288if (this._focusedModelTracker.read(undefined) !== tracker) {289this._focusedModelTracker.set(tracker, undefined);290}291292// Forward cursor position to the model tracker293if (cursorPosition) {294tracker.updateCursorPosition(cursorPosition);295}296});297298this._editorFocusTrackingDisposables.set(editor, focusDisposable);299}300}301302override dispose(): void {303for (const tracker of this._modelTrackers.values()) {304tracker.dispose();305}306this._modelTrackers.clear();307for (const disposable of this._editorFocusTrackingDisposables.values()) {308disposable.dispose();309}310this._editorFocusTrackingDisposables.clear();311super.dispose();312}313}314315registerSingleton(IRenameSymbolTrackerService, RenameSymbolTrackerService, InstantiationType.Delayed);316317318