import type { ConfigurationChangeEvent, Event, Position, Range, TextDocument, TextDocumentChangeEvent, TextDocumentContentChangeEvent, TextEditor, TextLine, } from 'vscode'; import { Disposable, EndOfLine, EventEmitter, Uri, window, workspace } from 'vscode'; import { configuration } from '../configuration'; import { ContextKeys } from '../constants'; import type { Container } from '../container'; import { setContext } from '../context'; import type { RepositoriesChangeEvent } from '../git/gitProviderService'; import type { GitUri } from '../git/gitUri'; import { isGitUri } from '../git/gitUri'; import type { RepositoryChangeEvent } from '../git/models/repository'; import { RepositoryChange, RepositoryChangeComparisonMode } from '../git/models/repository'; import { debug } from '../system/decorators/log'; import { once } from '../system/event'; import type { Deferrable } from '../system/function'; import { debounce } from '../system/function'; import { filter, join, map } from '../system/iterable'; import { findTextDocument, isActiveDocument, isTextEditor } from '../system/utils'; import type { DocumentBlameStateChangeEvent } from './trackedDocument'; import { TrackedDocument } from './trackedDocument'; export * from './trackedDocument'; export interface DocumentContentChangeEvent { readonly editor: TextEditor; readonly document: TrackedDocument; readonly contentChanges: ReadonlyArray; } export interface DocumentDirtyStateChangeEvent { readonly editor: TextEditor; readonly document: TrackedDocument; readonly dirty: boolean; } export interface DocumentDirtyIdleTriggerEvent { readonly editor: TextEditor; readonly document: TrackedDocument; } export class DocumentTracker implements Disposable { private _onDidChangeBlameState = new EventEmitter>(); get onDidChangeBlameState(): Event> { return this._onDidChangeBlameState.event; } private _onDidChangeContent = new EventEmitter>(); get onDidChangeContent(): Event> { return this._onDidChangeContent.event; } private _onDidChangeDirtyState = new EventEmitter>(); get onDidChangeDirtyState(): Event> { return this._onDidChangeDirtyState.event; } private _onDidTriggerDirtyIdle = new EventEmitter>(); get onDidTriggerDirtyIdle(): Event> { return this._onDidTriggerDirtyIdle.event; } private _dirtyIdleTriggerDelay: number; private readonly _disposable: Disposable; protected readonly _documentMap = new Map>>(); constructor(protected readonly container: Container) { this._disposable = Disposable.from( once(container.onReady)(this.onReady, this), configuration.onDidChange(this.onConfigurationChanged, this), window.onDidChangeActiveTextEditor(this.onActiveTextEditorChanged, this), // window.onDidChangeVisibleTextEditors(debounce(this.onVisibleEditorsChanged, 5000), this), workspace.onDidChangeTextDocument(debounce(this.onTextDocumentChanged, 50), this), workspace.onDidCloseTextDocument(this.onTextDocumentClosed, this), workspace.onDidSaveTextDocument(this.onTextDocumentSaved, this), this.container.git.onDidChangeRepositories(this.onRepositoriesChanged, this), this.container.git.onDidChangeRepository(this.onRepositoryChanged, this), ); this._dirtyIdleTriggerDelay = configuration.get('advanced.blame.delayAfterEdit'); } dispose() { this._disposable.dispose(); void this.clear(); } private onReady(): void { this.onConfigurationChanged(); this.onActiveTextEditorChanged(window.activeTextEditor); } private _timer: ReturnType | undefined; private onActiveTextEditorChanged(editor: TextEditor | undefined) { if (editor != null && !isTextEditor(editor)) return; if (this._timer != null) { clearTimeout(this._timer); this._timer = undefined; } if (editor == null) { this._timer = setTimeout(() => { this._timer = undefined; void setContext(ContextKeys.ActiveFileStatus, undefined); }, 250); return; } const doc = this._documentMap.get(editor.document); if (doc != null) { void doc.then( d => d.activate(), () => {}, ); return; } // No need to activate this, as it is implicit in initialization if currently active void this.addCore(editor.document); } private onConfigurationChanged(e?: ConfigurationChangeEvent) { // Only rest the cached state if we aren't initializing if ( e != null && (configuration.changed(e, 'blame.ignoreWhitespace') || configuration.changed(e, 'advanced.caching.enabled')) ) { this.reset('config'); } if (configuration.changed(e, 'advanced.blame.delayAfterEdit')) { this._dirtyIdleTriggerDelay = configuration.get('advanced.blame.delayAfterEdit'); this._dirtyIdleTriggeredDebounced = undefined; } } private onRepositoriesChanged(e: RepositoriesChangeEvent) { this.reset( 'repository', e.added.length ? new Set(e.added.map(r => r.path)) : undefined, e.removed.length ? new Set(e.removed.map(r => r.path)) : undefined, ); } private onRepositoryChanged(e: RepositoryChangeEvent) { if ( e.changed( RepositoryChange.Index, RepositoryChange.Heads, RepositoryChange.Status, RepositoryChange.Unknown, RepositoryChangeComparisonMode.Any, ) ) { this.reset('repository', new Set([e.repository.path])); } } private async onTextDocumentChanged(e: TextDocumentChangeEvent) { const { scheme } = e.document.uri; if (!this.container.git.supportedSchemes.has(scheme)) return; const doc = await (this._documentMap.get(e.document) ?? this.addCore(e.document)); doc.reset('document'); const dirty = e.document.isDirty; const editor = window.activeTextEditor; // If we have an idle tracker, either reset or cancel it if (this._dirtyIdleTriggeredDebounced != null) { if (dirty) { this._dirtyIdleTriggeredDebounced({ editor: editor!, document: doc }); } else { this._dirtyIdleTriggeredDebounced.cancel(); } } // Only fire change events for the active document if (editor?.document === e.document) { this._onDidChangeContent.fire({ editor: editor, document: doc, contentChanges: e.contentChanges }); } if (!doc.forceDirtyStateChangeOnNextDocumentChange && doc.dirty === dirty) return; doc.resetForceDirtyStateChangeOnNextDocumentChange(); doc.dirty = dirty; // Only fire state change events for the active document if (editor == null || editor.document !== e.document) return; this.fireDocumentDirtyStateChanged({ editor: editor, document: doc, dirty: doc.dirty }); } private onTextDocumentClosed(document: TextDocument) { void this.remove(document); } private async onTextDocumentSaved(document: TextDocument) { const doc = this._documentMap.get(document); if (doc != null) { void (await doc).update({ forceBlameChange: true }); return; } // If we are saving the active document make sure we are tracking it if (isActiveDocument(document)) { void this.addCore(document); } } // private onVisibleEditorsChanged(editors: TextEditor[]) { // if (this._documentMap.size === 0) return; // // If we have no visible editors, or no "real" visible editors reset our cache // if (editors.length === 0 || editors.every(e => !isTextEditor(e))) { // this.clear(); // } // } add(document: TextDocument): Promise>; add(uri: Uri): Promise>; add(documentOrUri: TextDocument | Uri): Promise>; async add(documentOrUri: TextDocument | Uri): Promise> { let document; if (isGitUri(documentOrUri)) { try { document = await workspace.openTextDocument(documentOrUri.documentUri()); } catch (ex) { const msg: string = ex?.toString() ?? ''; if (msg.includes('File seems to be binary and cannot be opened as text')) { document = new BinaryTextDocument(documentOrUri); } else if ( msg.includes('File not found') || msg.includes('Unable to read file') || msg.includes('Unable to resolve non-existing file') ) { // If we can't find the file, assume it is because the file has been renamed or deleted at some point document = new MissingRevisionTextDocument(documentOrUri); // const [fileName, repoPath] = await this.container.git.findWorkingFileName(documentOrUri, undefined, ref); // if (fileName == null) throw new Error(`Failed to add tracking for document: ${documentOrUri}`); // documentOrUri = await workspace.openTextDocument(path.resolve(repoPath!, fileName)); } else { throw ex; } } } else if (documentOrUri instanceof Uri) { document = await workspace.openTextDocument(documentOrUri); } else { document = documentOrUri; } const doc = this.addCore(document); return doc; } private async addCore(document: TextDocument): Promise> { const doc = TrackedDocument.create( document, // Always start out false, so we will fire the event if needed false, { onDidBlameStateChange: (e: DocumentBlameStateChangeEvent) => this._onDidChangeBlameState.fire(e), }, this.container, ); this._documentMap.set(document, doc); return doc; } async clear() { for (const d of this._documentMap.values()) { (await d).dispose(); } this._documentMap.clear(); } get(document: TextDocument): Promise> | undefined; get(uri: Uri): Promise> | undefined; get(documentOrUri: TextDocument | Uri): Promise> | undefined; get(documentOrUri: TextDocument | Uri): Promise> | undefined { if (documentOrUri instanceof Uri) { const document = findTextDocument(documentOrUri); if (document == null) return undefined; documentOrUri = document; } const doc = this._documentMap.get(documentOrUri); return doc; } async getOrAdd(documentOrUri: TextDocument | Uri): Promise> { if (documentOrUri instanceof Uri) { documentOrUri = findTextDocument(documentOrUri) ?? documentOrUri; } const doc = this.get(documentOrUri) ?? this.add(documentOrUri); return doc; } has(document: TextDocument): boolean; has(uri: Uri): boolean; has(documentOrUri: TextDocument | Uri): boolean { if (documentOrUri instanceof Uri) { const document = findTextDocument(documentOrUri); if (document == null) return false; documentOrUri = document; } return this._documentMap.has(documentOrUri); } private async remove(document: TextDocument, tracked?: TrackedDocument): Promise { let promise; if (tracked != null) { promise = this._documentMap.get(document); } this._documentMap.delete(document); (tracked ?? (await promise))?.dispose(); } private _dirtyIdleTriggeredDebounced: Deferrable<(e: DocumentDirtyIdleTriggerEvent) => void> | undefined; private _dirtyStateChangedDebounced: Deferrable<(e: DocumentDirtyStateChangeEvent) => void> | undefined; private fireDocumentDirtyStateChanged(e: DocumentDirtyStateChangeEvent) { if (e.dirty) { queueMicrotask(() => { this._dirtyStateChangedDebounced?.cancel(); if (window.activeTextEditor !== e.editor) return; this._onDidChangeDirtyState.fire(e); }); if (this._dirtyIdleTriggerDelay > 0) { if (this._dirtyIdleTriggeredDebounced == null) { this._dirtyIdleTriggeredDebounced = debounce( (e: DocumentDirtyIdleTriggerEvent) => { if (this._dirtyIdleTriggeredDebounced?.pending!()) return; e.document.isDirtyIdle = true; this._onDidTriggerDirtyIdle.fire(e); }, this._dirtyIdleTriggerDelay, { track: true }, ); } this._dirtyIdleTriggeredDebounced({ editor: e.editor, document: e.document }); } return; } if (this._dirtyStateChangedDebounced == null) { this._dirtyStateChangedDebounced = debounce((e: DocumentDirtyStateChangeEvent) => { if (window.activeTextEditor !== e.editor) return; this._onDidChangeDirtyState.fire(e); }, 250); } this._dirtyStateChangedDebounced(e); } @debug['reset']>({ args: { 1: c => (c != null ? join(c, ',') : ''), 2: r => (r != null ? join(r, ',') : ''), }, }) private reset(reason: 'config' | 'repository', changedRepoPaths?: Set, removedRepoPaths?: Set) { void Promise.allSettled( map( filter(this._documentMap, ([key]) => typeof key === 'string'), async ([, promise]) => { const doc = await promise; if (removedRepoPaths?.has(doc.uri.repoPath!)) { void this.remove(doc.document, doc); return; } if (changedRepoPaths == null || changedRepoPaths.has(doc.uri.repoPath!)) { doc.reset(reason); } }, ), ); } } class EmptyTextDocument implements TextDocument { readonly eol: EndOfLine; readonly fileName: string; readonly isClosed: boolean; readonly isDirty: boolean; readonly isUntitled: boolean; readonly languageId: string; readonly lineCount: number; readonly uri: Uri; readonly version: number; constructor(public readonly gitUri: GitUri) { this.uri = gitUri.documentUri(); this.eol = EndOfLine.LF; this.fileName = this.uri.fsPath; this.isClosed = false; this.isDirty = false; this.isUntitled = false; this.languageId = ''; this.lineCount = 0; this.version = 0; } getText(_range?: Range | undefined): string { throw new Error('Method not supported.'); } getWordRangeAtPosition(_position: Position, _regex?: RegExp | undefined): Range | undefined { throw new Error('Method not supported.'); } lineAt(line: number): TextLine; lineAt(position: Position): TextLine; lineAt(_position: any): TextLine { throw new Error('Method not supported.'); } offsetAt(_position: Position): number { throw new Error('Method not supported.'); } positionAt(_offset: number): Position { throw new Error('Method not supported.'); } save(): Thenable { throw new Error('Method not supported.'); } validatePosition(_position: Position): Position { throw new Error('Method not supported.'); } validateRange(_range: Range): Range { throw new Error('Method not supported.'); } } class BinaryTextDocument extends EmptyTextDocument {} class MissingRevisionTextDocument extends EmptyTextDocument {}