Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/inlineCompletions/browser/renameSymbolTrackerService.ts
5263 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js';
7
import { autorun, derived, IObservable, observableValue } from '../../../../base/common/observable.js';
8
import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js';
9
import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js';
10
import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';
11
import { IRenameSymbolTrackerService, type ITrackedWord } from '../../../../editor/browser/services/renameSymbolTrackerService.js';
12
import { Position } from '../../../../editor/common/core/position.js';
13
import { Range } from '../../../../editor/common/core/range.js';
14
import { StandardTokenType } from '../../../../editor/common/encodedTokenAttributes.js';
15
import { ITextModel } from '../../../../editor/common/model.js';
16
import { IModelContentChangedEvent } from '../../../../editor/common/textModelEvents.js';
17
import { TextModelEditSource } from '../../../../editor/common/textModelEditSource.js';
18
import { IModelService } from '../../../../editor/common/services/model.js';
19
import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js';
20
21
/**
22
* Checks if a model content change event was caused only by typing or pasting.
23
* Returns false for AI edits, refactorings, undo/redo, etc.
24
*/
25
function isUserEdit(event: IModelContentChangedEvent): boolean {
26
if (event.isUndoing || event.isRedoing || event.isFlush) {
27
return false;
28
}
29
30
for (const source of event.detailedReasons) {
31
if (!isUserEditSource(source)) {
32
return false;
33
}
34
}
35
36
return event.detailedReasons.length > 0;
37
}
38
39
const userEditKinds = new Set(['type', 'paste', 'cut', 'executeCommands', 'executeCommand', 'compositionType', 'compositionEnd']);
40
function isUserEditSource(source: TextModelEditSource): boolean {
41
const metadata = source.metadata;
42
if (metadata.source !== 'cursor') {
43
return false;
44
}
45
const kind = metadata.kind;
46
return userEditKinds.has(kind);
47
}
48
49
type WordState = {
50
word: string;
51
range: Range;
52
position: Position;
53
};
54
55
/**
56
* Tracks symbol edits for a single ITextModel.
57
*
58
* Receives cursor position updates from external sources (e.g., focused code editors).
59
* Only tracks edits done by typing or paste. Resets when:
60
* - A non-typing/paste edit occurs (AI, refactoring, undo/redo, etc.)
61
*/
62
class ModelSymbolRenameTracker extends Disposable {
63
private readonly _trackedWord = observableValue<ITrackedWord | undefined>(this, undefined);
64
public readonly trackedWord: IObservable<ITrackedWord | undefined> = this._trackedWord;
65
66
private _capturedWord: WordState | undefined = undefined;
67
private _lastWordBeforeEdit: WordState | undefined = undefined;
68
private _pendingContentChange: boolean = false;
69
private _lastCursorPosition: Position | undefined = undefined;
70
71
constructor(
72
private readonly _model: ITextModel
73
) {
74
super();
75
76
// Listen to content changes - only reset on non-typing/paste edits
77
this._register(this._model.onDidChangeContent(e => {
78
if (!isUserEdit(e)) {
79
// Non-user edit has occurred - reset rename tracking at
80
// the current cursor position (if any)
81
const position = this._lastCursorPosition;
82
this.reset();
83
if (position !== undefined) {
84
this.updateCursorPosition(position);
85
}
86
return;
87
}
88
// Valid typing/paste edit - mark that content changed, cursor update will handle tracking
89
this._pendingContentChange = true;
90
}));
91
}
92
93
/**
94
* Called by the service when the cursor position changes in an editor showing this model.
95
* Updates tracking based on the word under cursor and whether content has changed.
96
*/
97
public updateCursorPosition(position: Position): void {
98
this._lastCursorPosition = position;
99
const wordAtPosition = this._model.getWordAtPosition(position);
100
if (!wordAtPosition) {
101
// Not on a word - just clear lastWordBeforeEdit
102
this._lastWordBeforeEdit = undefined;
103
this._pendingContentChange = false;
104
return;
105
}
106
107
// Check if the position is in a comment
108
if (this._isPositionInComment(position)) {
109
this._lastWordBeforeEdit = undefined;
110
this._pendingContentChange = false;
111
return;
112
}
113
114
const currentWord: WordState = {
115
word: wordAtPosition.word,
116
range: new Range(
117
position.lineNumber,
118
wordAtPosition.startColumn,
119
position.lineNumber,
120
wordAtPosition.endColumn
121
),
122
position
123
};
124
125
const contentChanged = this._pendingContentChange;
126
this._pendingContentChange = false;
127
128
if (!contentChanged) {
129
// Just cursor movement - remember this word for later
130
this._lastWordBeforeEdit = currentWord;
131
return;
132
}
133
134
// Content changed - update tracking
135
if (!this._capturedWord) {
136
// First edit on a word - use the word from before the edit as original
137
const originalWord = this._lastWordBeforeEdit ?? currentWord;
138
this._capturedWord = { ...originalWord };
139
this._trackedWord.set({
140
model: this._model,
141
originalWord: originalWord.word,
142
originalPosition: originalWord.position,
143
originalRange: originalWord.range,
144
currentWord: currentWord.word,
145
currentRange: currentWord.range,
146
}, undefined);
147
this._lastWordBeforeEdit = currentWord;
148
return;
149
}
150
151
const capturedWord = this._capturedWord;
152
// Check if we're still on the same word (by position overlap or adjacency)
153
const isOnSameWord = this._rangesOverlap(capturedWord.range, currentWord.range) ||
154
this._isAdjacent(capturedWord.range, currentWord.range);
155
156
if (isOnSameWord) {
157
// Word has been edited - update the tracked word
158
this._trackedWord.set({
159
model: this._model,
160
originalWord: capturedWord.word,
161
originalPosition: capturedWord.position,
162
originalRange: capturedWord.range,
163
currentWord: currentWord.word,
164
currentRange: currentWord.range,
165
}, undefined);
166
} else {
167
// User started typing in a different word - use the word from before the edit as original
168
const originalWord = this._lastWordBeforeEdit ?? currentWord;
169
this._capturedWord = { ...originalWord };
170
this._trackedWord.set({
171
model: this._model,
172
originalWord: originalWord.word,
173
originalPosition: originalWord.position,
174
originalRange: originalWord.range,
175
currentWord: currentWord.word,
176
currentRange: currentWord.range,
177
}, undefined);
178
}
179
// Update lastWordBeforeEdit for the next iteration
180
this._lastWordBeforeEdit = currentWord;
181
}
182
183
private reset(): void {
184
this._trackedWord.set(undefined, undefined);
185
this._capturedWord = undefined;
186
this._lastWordBeforeEdit = undefined;
187
this._pendingContentChange = false;
188
this._lastCursorPosition = undefined;
189
}
190
191
private _isPositionInComment(position: Position): boolean {
192
this._model.tokenization.tokenizeIfCheap(position.lineNumber);
193
const tokens = this._model.tokenization.getLineTokens(position.lineNumber);
194
const tokenIndex = tokens.findTokenIndexAtOffset(position.column - 1);
195
const tokenType = tokens.getStandardTokenType(tokenIndex);
196
return tokenType === StandardTokenType.Comment;
197
}
198
199
private _rangesOverlap(a: Range, b: Range): boolean {
200
if (a.startLineNumber !== b.startLineNumber) {
201
return false;
202
}
203
return !(a.endColumn < b.startColumn || b.endColumn < a.startColumn);
204
}
205
206
private _isAdjacent(a: Range, b: Range): boolean {
207
if (a.startLineNumber !== b.startLineNumber) {
208
return false;
209
}
210
return a.endColumn === b.startColumn || b.endColumn === a.startColumn;
211
}
212
}
213
214
class RenameSymbolTrackerService extends Disposable implements IRenameSymbolTrackerService {
215
public _serviceBrand: undefined;
216
217
private readonly _modelTrackers = new Map<ITextModel, ModelSymbolRenameTracker>();
218
private readonly _editorFocusTrackingDisposables = new Map<ICodeEditor, IDisposable>();
219
220
private readonly _focusedModelTracker = observableValue<ModelSymbolRenameTracker | undefined>(this, undefined);
221
222
public readonly trackedWord: IObservable<ITrackedWord | undefined> = derived(this, reader => {
223
const tracker = this._focusedModelTracker.read(reader);
224
return tracker?.trackedWord.read(reader);
225
});
226
227
constructor(
228
@ICodeEditorService private readonly _codeEditorService: ICodeEditorService,
229
@IModelService private readonly _modelService: IModelService
230
) {
231
super();
232
233
// Setup tracking for existing editors
234
for (const editor of this._codeEditorService.listCodeEditors()) {
235
this._setupEditorTracking(editor);
236
}
237
238
// Track editor additions
239
this._register(this._codeEditorService.onCodeEditorAdd(editor => {
240
this._setupEditorTracking(editor);
241
}));
242
243
// Clean up editor focus tracking when editors are removed
244
this._register(this._codeEditorService.onCodeEditorRemove(editor => {
245
const focusDisposable = this._editorFocusTrackingDisposables.get(editor);
246
if (focusDisposable) {
247
focusDisposable.dispose();
248
this._editorFocusTrackingDisposables.delete(editor);
249
}
250
}));
251
252
// Clean up model trackers when models are removed
253
this._register(this._modelService.onModelRemoved(model => {
254
const tracker = this._modelTrackers.get(model);
255
if (tracker) {
256
tracker.dispose();
257
this._modelTrackers.delete(model);
258
}
259
}));
260
}
261
262
private _setupEditorTracking(editor: ICodeEditor): void {
263
if (editor.isSimpleWidget) {
264
return;
265
}
266
267
// Setup focus and cursor tracking
268
if (!this._editorFocusTrackingDisposables.has(editor)) {
269
const obsEditor = observableCodeEditor(editor);
270
271
const focusDisposable = autorun(reader => {
272
/** @description track focused editor and forward cursor to model tracker */
273
const isFocused = obsEditor.isFocused.read(reader);
274
const model = obsEditor.model.read(reader);
275
const cursorPosition = obsEditor.cursorPosition.read(reader);
276
277
if (!isFocused || !model) {
278
return;
279
}
280
281
// Ensure we have a tracker for this model
282
let tracker = this._modelTrackers.get(model);
283
if (!tracker) {
284
tracker = new ModelSymbolRenameTracker(model);
285
this._modelTrackers.set(model, tracker);
286
}
287
288
// Update the focused tracker
289
if (this._focusedModelTracker.read(undefined) !== tracker) {
290
this._focusedModelTracker.set(tracker, undefined);
291
}
292
293
// Forward cursor position to the model tracker
294
if (cursorPosition) {
295
tracker.updateCursorPosition(cursorPosition);
296
}
297
});
298
299
this._editorFocusTrackingDisposables.set(editor, focusDisposable);
300
}
301
}
302
303
override dispose(): void {
304
for (const tracker of this._modelTrackers.values()) {
305
tracker.dispose();
306
}
307
this._modelTrackers.clear();
308
for (const disposable of this._editorFocusTrackingDisposables.values()) {
309
disposable.dispose();
310
}
311
this._editorFocusTrackingDisposables.clear();
312
super.dispose();
313
}
314
}
315
316
registerSingleton(IRenameSymbolTrackerService, RenameSymbolTrackerService, InstantiationType.Delayed);
317
318