import { ViewPlugin, ViewUpdate } from '@codemirror/view'; import { Extension } from '@codemirror/state'; import { categoryForType, LogfireSettings } from '../types'; import { EventBus } from '../core/event-bus'; import { SessionManager } from '../core/session-manager'; interface PendingChange { inserted: number; deleted: number; from: number; to: number; } export class EditorCollector { constructor( private eventBus: EventBus, private sessionManager: SessionManager, private settings: LogfireSettings, private shouldTrack: (path: string) => boolean, private getActiveFilePath: () => string | null, ) {} createExtension(): Extension { const collector = this; return ViewPlugin.fromClass( class { private pendingChanges: PendingChange[] = []; private debounceTimer: ReturnType | null = null; private batchStartTime = 0; update(update: ViewUpdate): void { if (!update.docChanged) return; if (!collector.settings.tracking.editorChanges) return; const filePath = collector.getActiveFilePath(); if (!filePath || !collector.shouldTrack(filePath)) return; if (this.pendingChanges.length === 0) { this.batchStartTime = Date.now(); } update.changes.iterChanges((fromA, toA, _fromB, toB, inserted) => { this.pendingChanges.push({ inserted: inserted.length, deleted: toA - fromA, from: fromA, to: toB, }); }); if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); } this.debounceTimer = setTimeout( () => this.flush(filePath), collector.settings.advanced.debounceMs, ); } private flush(filePath: string): void { if (this.pendingChanges.length === 0) return; const changes = this.pendingChanges.splice(0); const duration = Date.now() - this.batchStartTime; let insertedChars = 0; let deletedChars = 0; let rangeStart = Infinity; let rangeEnd = 0; for (const c of changes) { insertedChars += c.inserted; deletedChars += c.deleted; if (c.from < rangeStart) rangeStart = c.from; if (c.to > rangeEnd) rangeEnd = c.to; } collector.emitChange(filePath, { insertedChars, deletedChars, rangeStart: rangeStart === Infinity ? 0 : rangeStart, rangeEnd, duration, }); } destroy(): void { if (this.debounceTimer !== null) { clearTimeout(this.debounceTimer); } } }, ); } private emitChange(source: string, payload: Record): void { this.eventBus.emit({ id: crypto.randomUUID(), timestamp: Date.now(), type: 'editor:change', category: categoryForType('editor:change'), source, payload, session: this.sessionManager.currentSessionId, }); } }