diff --git a/vscode/src/services/CharactersLogger.test.ts b/vscode/src/services/CharactersLogger.test.ts index 7ce7889aad6..66a59aa7b3f 100644 --- a/vscode/src/services/CharactersLogger.test.ts +++ b/vscode/src/services/CharactersLogger.test.ts @@ -8,10 +8,12 @@ import { document } from '../completions/test-helpers' import { range } from '../testutils/textDocument' import { + type CharacterLoggerCounters, CharactersLogger, DEFAULT_COUNTERS, - type DocumentChangeCounters, LOG_INTERVAL, + RAPID_CHANGE_TIMEOUT, + SELECTION_TIMEOUT, } from './CharactersLogger' const testDocument = document('foo') @@ -20,24 +22,26 @@ describe('CharactersLogger', () => { let recordSpy: MockInstance let tracker: CharactersLogger + let onDidChangeActiveTextEditor: (event: vscode.TextEditor | undefined) => void let onDidChangeTextDocument: (event: vscode.TextDocumentChangeEvent) => void + let onDidCloseTextDocument: (document: vscode.TextDocument) => void let onDidChangeWindowState: (state: vscode.WindowState) => void - let onDidChangeVisibleTextEditors: (editors: vscode.TextEditor[]) => void let onDidChangeTextEditorSelection: (event: vscode.TextEditorSelectionChangeEvent) => void let mockWindowState: Writable - let mockVisibleTextEditors: vscode.TextEditor[] + let mockActiveTextEditor: Writable | undefined let mockTextEditorSelectionEvent: vscode.TextEditorSelectionChangeEvent beforeEach(() => { vi.useFakeTimers() - vi.setSystemTime(0) // Start at timestamp 0 for consistency recordSpy = vi.spyOn(telemetryRecorder, 'recordEvent') - // Mock functions and variables mockWindowState = { focused: true } - mockVisibleTextEditors = [{ document: testDocument } as vscode.TextEditor] + mockActiveTextEditor = { + document: testDocument, + visibleRanges: [range(0, 0, 1000, 1000)], + } as unknown as vscode.TextEditor mockTextEditorSelectionEvent = { textEditor: { document: testDocument } as vscode.TextEditor, @@ -51,21 +55,26 @@ describe('CharactersLogger', () => { onDidChangeTextDocument = listener return { dispose: () => {} } }, + onDidCloseTextDocument(listener) { + onDidCloseTextDocument = listener + return { dispose: () => {} } + }, }, { + state: mockWindowState, + activeTextEditor: mockActiveTextEditor, onDidChangeWindowState(listener) { onDidChangeWindowState = listener return { dispose: () => {} } }, - onDidChangeVisibleTextEditors(listener) { - onDidChangeVisibleTextEditors = listener + onDidChangeActiveTextEditor(listener) { + onDidChangeActiveTextEditor = listener return { dispose: () => {} } }, onDidChangeTextEditorSelection(listener) { onDidChangeTextEditorSelection = listener return { dispose: () => {} } }, - visibleTextEditors: mockVisibleTextEditors, } ) }) @@ -103,264 +112,174 @@ describe('CharactersLogger', () => { } } - // Helper function to create default metadata counters with expected values - function expectedCounters(expected: Partial): Record { + // Helper function to create expected counters + function expectedCharCounters(expected: Partial): Record { return { ...DEFAULT_COUNTERS, ...expected } } - it('logs inserted and deleted characters for user edits', () => { + it('should handle insertions, deletions, rapid and stale changes, and changes outside of visible range', () => { + // Simulate user typing in the active text editor + onDidChangeActiveTextEditor(mockActiveTextEditor) onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) - onDidChangeTextDocument(createChange({ text: 'foo', range: range(0, 0, 0, 0), rangeLength: 0 })) - onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) - onDidChangeTextDocument(createChange({ text: 'bar', range: range(0, 3, 0, 3), rangeLength: 0 })) - vi.advanceTimersByTime(LOG_INTERVAL) - - expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - normal_inserted: 6, - normal_deleted: 0, - }), - }) - }) + // Scenario 1: User types 'hello' (insertion) + onDidChangeTextDocument( + createChange({ text: 'hello', range: range(0, 0, 0, 0), rangeLength: 0 }) + ) - it('logs changes under "windowNotFocused" when window is not focused', () => { - onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) - onDidChangeTextDocument(createChange({ text: 'foo', range: range(0, 0, 0, 0), rangeLength: 0 })) + // Advance time less than RAPID_CHANGE_TIMEOUT to simulate rapid change + vi.advanceTimersByTime(RAPID_CHANGE_TIMEOUT - 5) - // Simulate window losing focus - mockWindowState.focused = false - onDidChangeWindowState(mockWindowState) + // Scenario 2: User deletes 'he' (deletion) + onDidChangeTextDocument(createChange({ text: '', range: range(0, 0, 0, 2), rangeLength: 2 })) - onDidChangeTextDocument(createChange({ text: 'bar', range: range(0, 3, 0, 3), rangeLength: 0 })) + // Now, advance time beyond SELECTION_TIMEOUT to make selection stale + vi.advanceTimersByTime(SELECTION_TIMEOUT + 1000) - vi.advanceTimersByTime(LOG_INTERVAL) + // Scenario 3: User types 'there' (stale insertion) + onDidChangeTextDocument( + createChange({ text: 'there', range: range(0, 3, 0, 3), rangeLength: 0 }) + ) + // Should be counted as an insertion, stale change - // Changes while focused and not focused are logged under different types - expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - normal_inserted: 3, - normal_deleted: 0, - windowNotFocused_inserted: 3, - windowNotFocused_deleted: 0, - }), - }) - }) + // Scenario 4: Change outside of visible range + // Simulate that the change is outside the visible range + mockActiveTextEditor!.visibleRanges = [range(1, 0, 1, 0)] // Lines 1 to 1 (empty range) + onDidChangeActiveTextEditor(mockActiveTextEditor) - it('logs changes under "nonVisibleDocument" when in non-visible documents', () => { - // Remove testDocument from visible editors - mockVisibleTextEditors = [] - onDidChangeVisibleTextEditors(mockVisibleTextEditors) + // User types 'hidden' at line 50 (outside visible range) + onDidChangeTextDocument( + createChange({ + text: 'hidden', + range: range(50, 0, 50, 0), // Line 50, start + rangeLength: 0, + }) + ) - onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) - onDidChangeTextDocument(createChange({ text: 'foo', range: range(0, 0, 0, 0), rangeLength: 0 })) + // Restore the visible ranges + mockActiveTextEditor!.visibleRanges = [range(0, 0, 1000, 0)] + // Flush the log vi.advanceTimersByTime(LOG_INTERVAL) - // Change is logged under 'nonVisibleDocument' + // Expected counters: expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - nonVisibleDocument_inserted: 3, - nonVisibleDocument_deleted: 0, - }), - }) - }) + metadata: expectedCharCounters({ + xxs_change: 1, + xxs_change_inserted: 5, // 'hello' - it('logs changes under "inactiveSelection" when there has been no recent cursor movement', () => { - // Simulate last selection happened 6 seconds ago - onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) - vi.advanceTimersByTime(6000) + rapid_xxxs_change: 1, + rapid_xxxs_change_deleted: 2, // 'he' - onDidChangeTextDocument(createChange({ text: 'foo', range: range(0, 0, 0, 0), rangeLength: 0 })) + stale_xxs_change: 1, + stale_xxs_change_inserted: 5, // 'there' - vi.advanceTimersByTime(LOG_INTERVAL - 6000) - - // Change is logged under 'inactiveSelection' - expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - inactiveSelection_inserted: 3, - inactiveSelection_deleted: 0, + outside_of_visible_ranges: 1, + outside_of_visible_ranges_inserted: 6, // 'hidden' }), }) }) - it('logs undo and redo changes under their respective types', () => { + it('should handle undo, redo, window not focused, no active editor, outside of active editor, and document closing', () => { + onDidChangeActiveTextEditor(mockActiveTextEditor) onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) - const textDocumentChangeReason = { - undo: 1, - redo: 2, - } - // Simulate undo change + const changeReasons = { Undo: 1, Redo: 2 } as const + + onDidChangeTextDocument(createChange({ text: 'test', range: range(0, 0, 0, 0), rangeLength: 0 })) + + // Simulate undo onDidChangeTextDocument( createChange({ text: '', - range: range(0, 3, 0, 0), - rangeLength: 3, - document: testDocument, - reason: textDocumentChangeReason.undo, + range: range(0, 4, 0, 0), + rangeLength: 4, + reason: changeReasons.Undo, }) ) - // Simulate redo change + // Simulate redo onDidChangeTextDocument( createChange({ - text: 'foo', + text: 'test', range: range(0, 0, 0, 0), rangeLength: 0, - document: testDocument, - reason: textDocumentChangeReason.redo, + reason: changeReasons.Redo, }) ) - vi.advanceTimersByTime(LOG_INTERVAL) - - // Changes are logged under 'undo' and 'redo' - expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - undo_inserted: 0, - undo_deleted: 3, - redo_inserted: 3, - redo_deleted: 0, - }), - }) - }) - - it('logs rapid, large changes under "rapidLargeChange"', () => { - onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) + mockWindowState.focused = false + onDidChangeWindowState(mockWindowState) - // Simulate large change (e.g., 2000 characters inserted) - const largeText = 'a'.repeat(2000) + // User types ' window not focused' when window not focused onDidChangeTextDocument( - createChange({ text: largeText, range: range(0, 0, 0, 0), rangeLength: 0 }) + createChange({ text: 'window not focused', range: range(0, 4, 0, 4), rangeLength: 0 }) ) - vi.advanceTimersByTime(LOG_INTERVAL) - - // Change is logged under 'rapidLargeChange' - expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - rapidLargeChange_inserted: 2000, - rapidLargeChange_deleted: 0, - }), - }) - }) - - it('counts large changes as "normal" if they are not rapid', () => { - onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) + // Simulate window gaining focus + mockWindowState.focused = true + onDidChangeWindowState(mockWindowState) - // Simulate large change after some time has passed - vi.advanceTimersByTime(2000) // Advance time beyond LARGE_CHANGE_TIMEOUT - const largeText = 'a'.repeat(2000) + // Simulate no active editor + onDidChangeActiveTextEditor(undefined) onDidChangeTextDocument( - createChange({ text: largeText, range: range(0, 0, 0, 0), rangeLength: 0 }) + createChange({ text: 'no active editor', range: range(0, 21, 0, 21), rangeLength: 0 }) ) - vi.advanceTimersByTime(LOG_INTERVAL - 2000) - - // The large change is logged under 'normal' - expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - normal_inserted: 2000, - normal_deleted: 0, - }), - }) - }) - - it('resets counter after flushing', () => { - onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) - onDidChangeTextDocument(createChange({ text: 'foo', range: range(0, 0, 0, 0), rangeLength: 0 })) - - vi.advanceTimersByTime(LOG_INTERVAL) - - expect(recordSpy).toHaveBeenCalledTimes(1) - expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - normal_inserted: 3, - normal_deleted: 0, - }), - }) - - onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) - onDidChangeTextDocument(createChange({ text: 'bar', range: range(0, 3, 0, 3), rangeLength: 0 })) - - vi.advanceTimersByTime(LOG_INTERVAL) - - expect(recordSpy).toHaveBeenCalledTimes(2) - expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - normal_inserted: 3, - normal_deleted: 0, - }), - }) - }) - - it('logs user typing under "normal" after cursor movement', () => { - // Simulate user moving the cursor - onDidChangeTextEditorSelection(mockTextEditorSelectionEvent) - - // Simulate user typing - onDidChangeTextDocument(createChange({ text: 'foo', range: range(0, 0, 0, 0), rangeLength: 0 })) + // Simulate editor for different document + const anotherDocument = { + uri: { toString: () => 'file://anotherdocument' }, + } as vscode.TextDocument - vi.advanceTimersByTime(LOG_INTERVAL) - - // Changes are logged under 'normal' - expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - normal_inserted: 3, - normal_deleted: 0, - }), - }) - }) - - it('handles multiple documents and selections', () => { - const anotherDocument = document('bar') - mockVisibleTextEditors.push({ + const anotherEditor = { document: anotherDocument, - } as vscode.TextEditor) - onDidChangeVisibleTextEditors(mockVisibleTextEditors) + visibleRanges: [range(0, 0, 1000, 0)], + } as unknown as vscode.TextEditor - // Simulate cursor movement in both documents - onDidChangeTextEditorSelection({ - textEditor: { - document: testDocument, - } as vscode.TextEditor, - selections: [], - kind: undefined, - }) - onDidChangeTextEditorSelection({ - textEditor: { - document: anotherDocument, - } as vscode.TextEditor, - selections: [], - kind: undefined, - }) + onDidChangeActiveTextEditor(anotherEditor) - // Simulate changes in both documents + // User types in original document (not the active editor's document) onDidChangeTextDocument( createChange({ - text: 'foo', - range: range(0, 0, 0, 0), + text: 'outside active editor', + range: range(0, 21, 0, 21), rangeLength: 0, document: testDocument, }) ) + + onDidCloseTextDocument(testDocument) onDidChangeTextDocument( createChange({ - text: 'baz', - range: range(0, 0, 0, 0), + text: '!', + range: range(0, 50, 0, 50), rangeLength: 0, - document: anotherDocument, + document: testDocument, }) ) vi.advanceTimersByTime(LOG_INTERVAL) - // Changes in both documents should be counted under 'normal' + // Expected counters: expect(recordSpy).toHaveBeenCalledWith('cody.characters', 'flush', { - metadata: expectedCounters({ - normal_inserted: 6, - normal_deleted: 0, + metadata: expectedCharCounters({ + xxs_change: 1, + xxs_change_inserted: 4, // 'test' + + undo: 1, + undo_deleted: 4, // 'test' deleted + + redo: 1, + redo_inserted: 4, // 'test' re-inserted + + window_not_focused: 1, + window_not_focused_inserted: 18, // ' window not focused' + + no_active_editor: 1, + no_active_editor_inserted: 16, // 'no active editor' + + outside_of_active_editor: 2, + outside_of_active_editor_inserted: 22, //'outside active editor' }), }) }) diff --git a/vscode/src/services/CharactersLogger.ts b/vscode/src/services/CharactersLogger.ts index f561c9c3103..50d5efc7644 100644 --- a/vscode/src/services/CharactersLogger.ts +++ b/vscode/src/services/CharactersLogger.ts @@ -1,94 +1,122 @@ -import { isFileURI } from '@sourcegraph/cody-shared' import * as vscode from 'vscode' -import { telemetryRecorder } from '@sourcegraph/cody-shared' +import { isFileURI, telemetryRecorder } from '@sourcegraph/cody-shared' +import { outputChannelLogger } from '../output-channel-logger' export const LOG_INTERVAL = 30 * 60 * 1000 // 30 minutes -const LARGE_CHANGE_THRESHOLD = 1000 -const LARGE_CHANGE_TIMEOUT = 1000 // Ignore large changes happened within this time. -const SELECTION_TIMEOUT = 5000 // 5 seconds +export const RAPID_CHANGE_TIMEOUT = 15 +export const SELECTION_TIMEOUT = 5000 + +export const changeBoundaries = { + xxxs_change: { min: 0, max: 2 }, + xxs_change: { min: 3, max: 10 }, + xs_change: { min: 11, max: 50 }, + s_change: { min: 51, max: 200 }, + m_change: { min: 200, max: 1000 }, + l_change: { min: 1001, max: 3000 }, + xl_change: { min: 3001, max: 10000 }, + xxl_change: { min: 10001, max: 50000 }, + xxxl_change: { min: 50001, max: Number.POSITIVE_INFINITY }, +} as const + +const changeBoundariesKeys = Object.keys(changeBoundaries) as (keyof typeof changeBoundaries)[] +const staleAndRapidChangeBoundariesKeys = changeBoundariesKeys.flatMap( + key => [`stale_${key}`, `rapid_${key}`, `rapid_stale_${key}`] as const +) -const DOCUMENT_CHANGE_TYPES = [ - 'normal', +const SPECIAL_DOCUMENT_CHANGE_TYPES = [ 'undo', 'redo', - 'windowNotFocused', - 'nonVisibleDocument', - 'inactiveSelection', - 'rapidLargeChange', + 'window_not_focused', + 'no_active_editor', + 'outside_of_active_editor', + 'outside_of_visible_ranges', + 'unexpected', // should not be logged because all the change sizes are covered by the keys above +] as const + +vscode.window.activeTextEditor +const DOCUMENT_CHANGE_TYPES = [ + ...SPECIAL_DOCUMENT_CHANGE_TYPES, + ...changeBoundariesKeys, + ...staleAndRapidChangeBoundariesKeys, ] as const type DocumentChangeType = (typeof DOCUMENT_CHANGE_TYPES)[number] // This flat structure is required by the 'metadata' field type in the telemetry event. -export type DocumentChangeCounters = { - [K in `${DocumentChangeType}_${'inserted' | 'deleted'}`]: number +export type CharacterLoggerCounters = { + [K in `${DocumentChangeType}_${'inserted' | 'deleted'}` | DocumentChangeType]: number } -export const DEFAULT_COUNTERS: DocumentChangeCounters = DOCUMENT_CHANGE_TYPES.reduce( - (acc, changeType) => { - acc[`${changeType}_inserted`] = 0 - acc[`${changeType}_deleted`] = 0 - return acc - }, - {} as DocumentChangeCounters -) +export const DEFAULT_COUNTERS = DOCUMENT_CHANGE_TYPES.reduce((acc, changeType) => { + // To count the number of characters inserted/deleted + acc[`${changeType}_inserted`] = 0 + acc[`${changeType}_deleted`] = 0 + + // To count the number of events + acc[changeType] = 0 + + return acc +}, {} as CharacterLoggerCounters) export class CharactersLogger implements vscode.Disposable { private disposables: vscode.Disposable[] = [] - private changeCounters: DocumentChangeCounters = { ...DEFAULT_COUNTERS } + private changeCounters: CharacterLoggerCounters = { ...DEFAULT_COUNTERS } private nextTimeoutId: NodeJS.Timeout | null = null private windowFocused = true - private visibleDocuments = new Set() + private activeTextEditor: vscode.TextEditor | undefined private lastChangeTimestamp = 0 private lastSelectionTimestamps = new Map() constructor( - workspace: Pick = vscode.workspace, + workspace: Pick< + typeof vscode.workspace, + 'onDidChangeTextDocument' | 'onDidCloseTextDocument' + > = vscode.workspace, window: Pick< typeof vscode.window, + | 'state' + | 'activeTextEditor' | 'onDidChangeWindowState' - | 'onDidChangeVisibleTextEditors' + | 'onDidChangeActiveTextEditor' | 'onDidChangeTextEditorSelection' - | 'visibleTextEditors' > = vscode.window ) { - this.disposables.push(workspace.onDidChangeTextDocument(this.onDidChangeTextDocument.bind(this))) - this.disposables.push(window.onDidChangeWindowState(this.onDidChangeWindowState.bind(this))) - this.disposables.push( - window.onDidChangeVisibleTextEditors(this.onDidChangeVisibleTextEditors.bind(this)) - ) this.disposables.push( - window.onDidChangeTextEditorSelection(this.onDidChangeTextEditorSelection.bind(this)) + workspace.onDidChangeTextDocument(this.onDidChangeTextDocument.bind(this)), + workspace.onDidCloseTextDocument(document => { + this.lastSelectionTimestamps.delete(document.uri.toString()) + }), + window.onDidChangeWindowState(state => { + this.windowFocused = state.focused + }), + window.onDidChangeActiveTextEditor(editor => { + this.activeTextEditor = editor + }), + window.onDidChangeTextEditorSelection(event => { + const documentUri = event.textEditor.document.uri.toString() + this.lastSelectionTimestamps.set(documentUri, Date.now()) + }) ) - this.updateVisibleDocuments(window.visibleTextEditors) + this.windowFocused = window.state.focused + this.activeTextEditor = window.activeTextEditor this.nextTimeoutId = setTimeout(() => this.flush(), LOG_INTERVAL) } public flush(): void { - this.nextTimeoutId = null - - telemetryRecorder.recordEvent('cody.characters', 'flush', { - metadata: { ...this.changeCounters }, - }) - this.changeCounters = { ...DEFAULT_COUNTERS } - - this.nextTimeoutId = setTimeout(() => this.flush(), LOG_INTERVAL) - } - - private onDidChangeWindowState(state: vscode.WindowState): void { - this.windowFocused = state.focused - } - - private onDidChangeVisibleTextEditors(editors: Readonly): void { - this.updateVisibleDocuments(editors) - } - - private onDidChangeTextEditorSelection(event: vscode.TextEditorSelectionChangeEvent): void { - const documentUri = event.textEditor.document.uri.toString() - this.lastSelectionTimestamps.set(documentUri, Date.now()) + try { + this.nextTimeoutId = null + telemetryRecorder.recordEvent('cody.characters', 'flush', { + metadata: { ...this.changeCounters }, + }) + } catch (error) { + outputChannelLogger.logError('CharactersLogger', 'Failed to record telemetry event:', error) + } finally { + this.changeCounters = { ...DEFAULT_COUNTERS } + this.nextTimeoutId = setTimeout(() => this.flush(), LOG_INTERVAL) + } } private onDidChangeTextDocument(event: vscode.TextDocumentChangeEvent): void { @@ -96,74 +124,89 @@ export class CharactersLogger implements vscode.Disposable { return } - const changeType = this.getDocumentChangeType(event) + const totalChangeSize = event.contentChanges.reduce((sum, change) => { + return sum + Math.abs(change.rangeLength) + Math.abs(change.text.length) + }, 0) + + const changeType = this.getDocumentChangeType(event, totalChangeSize) + const { activeTextEditor } = this for (const change of event.contentChanges) { + const isChangeVisible = activeTextEditor?.visibleRanges.some(range => { + return range.contains(change.range) + }) + + const isSpecialChangeType = SPECIAL_DOCUMENT_CHANGE_TYPES.find(v => v === changeType) + const contentChangeType = + !isSpecialChangeType && !isChangeVisible ? 'outside_of_visible_ranges' : changeType + + this.changeCounters[contentChangeType]++ + // We use change.rangeLength for deletions because: // 1. It represents the length of the text being replaced, including newline characters. // 2. It accurately accounts for multi-line deletions. // 3. For pure deletions (without insertions), this will be the number of characters removed. // 4. For replacements, this represents the "old" text that's being replaced. - this.changeCounters[`${changeType}_deleted`] += change.rangeLength + this.changeCounters[`${contentChangeType}_deleted`] += change.rangeLength // We use change.text.length for insertions because: // 1. It represents the length of the new text being inserted, including newline characters. // 2. It accurately accounts for multi-line insertions. // 3. For pure insertions (without deletions), this will be the number of characters added. // 4. For replacements, this represents the "new" text that's replacing the old. - this.changeCounters[`${changeType}_inserted`] += change.text.length + this.changeCounters[`${contentChangeType}_inserted`] += change.text.length // Note: In the case of replacements, both deleted and inserted will be incremented. // This accurately represents that some text was removed and some was added, even if // the lengths are the same. } - this.lastChangeTimestamp = Date.now() + if (totalChangeSize > 0) { + this.lastChangeTimestamp = Date.now() + } } - private getDocumentChangeType(event: vscode.TextDocumentChangeEvent): DocumentChangeType { + private getDocumentChangeType( + event: vscode.TextDocumentChangeEvent, + totalChangeSize: number + ): DocumentChangeType { const currentTimestamp = Date.now() const documentUri = event.document.uri.toString() if (event.reason === vscode.TextDocumentChangeReason.Undo) { return 'undo' } + if (event.reason === vscode.TextDocumentChangeReason.Redo) { return 'redo' } if (!this.windowFocused) { - return 'windowNotFocused' - } - if (!this.visibleDocuments.has(documentUri)) { - return 'nonVisibleDocument' + return 'window_not_focused' } - const lastSelectionTimestamp = this.lastSelectionTimestamps.get(documentUri) || 0 - const timeSinceLastSelection = currentTimestamp - lastSelectionTimestamp - - if (timeSinceLastSelection > SELECTION_TIMEOUT) { - return 'inactiveSelection' + if (!this.activeTextEditor) { + return 'no_active_editor' } - const timeSinceLastChange = currentTimestamp - this.lastChangeTimestamp - const totalChangeSize = event.contentChanges.reduce((sum, change) => { - return sum + Math.abs(change.rangeLength) + Math.abs(change.text.length) - }, 0) - - if (totalChangeSize > LARGE_CHANGE_THRESHOLD && timeSinceLastChange < LARGE_CHANGE_TIMEOUT) { - return 'rapidLargeChange' + if (this.activeTextEditor.document.uri.toString() !== documentUri) { + return 'outside_of_active_editor' } - return 'normal' - } + const lastSelectionTimestamp = this.lastSelectionTimestamps.get(documentUri) || 0 + const isSelectionStale = currentTimestamp - lastSelectionTimestamp > SELECTION_TIMEOUT + const isRapidChange = currentTimestamp - this.lastChangeTimestamp < RAPID_CHANGE_TIMEOUT - private updateVisibleDocuments(editors: Readonly): void { - this.visibleDocuments.clear() - for (const editor of editors) { - const uri = editor.document.uri.toString() - this.visibleDocuments.add(uri) + const rapidPrefix = isRapidChange ? 'rapid_' : '' + const stalePrefix = isSelectionStale ? 'stale_' : '' + + for (const [changeSizeType, boundaries] of Object.entries(changeBoundaries)) { + if (boundaries.min <= totalChangeSize && totalChangeSize <= boundaries.max) { + return `${rapidPrefix}${stalePrefix}${changeSizeType as keyof typeof changeBoundaries}` + } } + + return 'unexpected' } public dispose(): void { diff --git a/vscode/src/testutils/mocks.ts b/vscode/src/testutils/mocks.ts index f8b94062931..7c370655361 100644 --- a/vscode/src/testutils/mocks.ts +++ b/vscode/src/testutils/mocks.ts @@ -357,6 +357,14 @@ export class Location implements VSCodeLocation { } } +const isSmallerOrEqualPosition = (a: Position, b: Position): boolean => { + return a.line < b.line || (a.line === b.line && a.character <= b.character) +} + +const isBiggerOrEqualPosition = (a: Position, b: Position): boolean => { + return a.line > b.line || (a.line === b.line && a.character >= b.character) +} + export class Range implements VSCodeRange { public start: Position public end: Position @@ -418,21 +426,17 @@ export class Range implements VSCodeRange { return this.start.line === this.end.line } public contains(positionOrRange: Position | Range): boolean { - const isSmallerOrEqual = (a: Position, b: Position): boolean => { - return a.line < b.line || (a.line === b.line && a.character <= b.character) - } - if ('line' in positionOrRange) { return ( - isSmallerOrEqual(this.start, positionOrRange) && - isSmallerOrEqual(positionOrRange, this.end) + isSmallerOrEqualPosition(this.start, positionOrRange) && + isSmallerOrEqualPosition(positionOrRange, this.end) ) } if ('start' in positionOrRange && 'end' in positionOrRange) { return ( - isSmallerOrEqual(this.start, positionOrRange.start) && - isSmallerOrEqual(positionOrRange.start, this.end) && - isSmallerOrEqual(this.end, positionOrRange.end) + isSmallerOrEqualPosition(this.start, positionOrRange.start) && + isSmallerOrEqualPosition(positionOrRange.start, this.end) && + isBiggerOrEqualPosition(this.end, positionOrRange.end) ) }