import { CancellationToken, DecorationOptions, Disposable, Hover, languages, Position, Range, Selection, TextDocument, TextEditor, TextEditorDecorationType, TextEditorRevealType, } from 'vscode'; import { FileAnnotationType } from '../configuration'; import { Container } from '../container'; import { GitCommit, GitDiff } from '../git/models'; import { Hovers } from '../hovers/hovers'; import { Logger } from '../logger'; import { log } from '../system/decorators/log'; import { Stopwatch } from '../system/stopwatch'; import { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; import { AnnotationContext, AnnotationProviderBase } from './annotationProvider'; import { Decorations } from './fileAnnotationController'; export interface ChangesAnnotationContext extends AnnotationContext { sha?: string; only?: boolean; } export class GutterChangesAnnotationProvider extends AnnotationProviderBase { private state: { commit: GitCommit | undefined; diffs: GitDiff[] } | undefined; private hoverProviderDisposable: Disposable | undefined; constructor( editor: TextEditor, trackedDocument: TrackedDocument, private readonly container: Container, ) { super(FileAnnotationType.Changes, editor, trackedDocument); } override mustReopen(context?: ChangesAnnotationContext): boolean { return this.annotationContext?.sha !== context?.sha || this.annotationContext?.only !== context?.only; } override clear() { this.state = undefined; if (this.hoverProviderDisposable != null) { this.hoverProviderDisposable.dispose(); this.hoverProviderDisposable = undefined; } super.clear(); } selection(_selection?: AnnotationContext['selection']): Promise { return Promise.resolve(); } validate(): Promise { return Promise.resolve(true); } @log() async onProvideAnnotation(context?: ChangesAnnotationContext): Promise { const cc = Logger.getCorrelationContext(); if (this.mustReopen(context)) { this.clear(); } this.annotationContext = context; let ref1 = this.trackedDocument.uri.sha; let ref2 = context?.sha != null && context.sha !== ref1 ? `${context.sha}^` : undefined; let commit: GitCommit | undefined; let localChanges = ref1 == null && ref2 == null; if (localChanges) { let ref = await this.container.git.getOldestUnpushedRefForFile( this.trackedDocument.uri.repoPath!, this.trackedDocument.uri, ); if (ref != null) { ref = `${ref}^`; commit = await this.container.git.getCommitForFile( this.trackedDocument.uri.repoPath, this.trackedDocument.uri, { ref: ref }, ); if (commit != null) { if (ref2 != null) { ref2 = ref; } else { ref1 = ref; ref2 = ''; } } else { localChanges = false; } } else { const status = await this.container.git.getStatusForFile( this.trackedDocument.uri.repoPath!, this.trackedDocument.uri, ); const commits = status?.getPseudoCommits( this.container, await this.container.git.getCurrentUser(this.trackedDocument.uri.repoPath!), ); if (commits?.length) { commit = await this.container.git.getCommitForFile( this.trackedDocument.uri.repoPath, this.trackedDocument.uri, ); ref1 = 'HEAD'; } else if (this.trackedDocument.dirty) { ref1 = 'HEAD'; } else { localChanges = false; } } } if (!localChanges) { commit = await this.container.git.getCommitForFile( this.trackedDocument.uri.repoPath, this.trackedDocument.uri, { ref: ref2 ?? ref1, }, ); if (commit == null) return false; if (ref2 != null) { ref2 = commit.ref; } else { ref1 = `${commit.ref}^`; ref2 = commit.ref; } } const diffs = ( await Promise.all( ref2 == null && this.editor.document.isDirty ? [ this.container.git.getDiffForFileContents( this.trackedDocument.uri, ref1!, this.editor.document.getText(), ), this.container.git.getDiffForFile(this.trackedDocument.uri, ref1, ref2), ] : [this.container.git.getDiffForFile(this.trackedDocument.uri, ref1, ref2)], ) ).filter((d?: T): d is T => Boolean(d)); if (!diffs?.length) return false; const sw = new Stopwatch(cc!); const decorationsMap = new Map< string, { decorationType: TextEditorDecorationType; rangesOrOptions: DecorationOptions[] } >(); // If we want to only show changes from the specified sha, get the blame so we can compare with "visible" shas const blame = context?.sha != null && context?.only ? await this.container.git.getBlame(this.trackedDocument.uri, this.editor?.document) : undefined; let selection: Selection | undefined; for (const diff of diffs) { for (const hunk of diff.hunks) { // Only show "visible" hunks if (blame != null) { let skip = true; const sha = context!.sha; for (let i = hunk.current.position.start - 1; i < hunk.current.position.end; i++) { if (blame.lines[i].sha === sha) { skip = false; } } if (skip) { continue; } } // Subtract 2 because editor lines are 0-based and we will be adding 1 in the first iteration of the loop let count = Math.max(hunk.current.position.start - 2, -1); let index = -1; for (const hunkLine of hunk.lines) { index++; count++; if (hunkLine.current?.state === 'unchanged') continue; // Uncomment this if we want to only show "visible" lines, rather than just visible hunks // if (blame != null && blame.lines[count].sha !== context!.sha) { // continue; // } const range = this.editor.document.validateRange( new Range(new Position(count, 0), new Position(count, Number.MAX_SAFE_INTEGER)), ); if (selection == null) { selection = new Selection(range.start, range.end); } let state; if (hunkLine.current == null) { const previous = hunk.lines[index - 1]; if (hunkLine.previous != null && (previous == null || previous.current != null)) { // Check if there are more deleted lines than added lines show a deleted indicator if (hunk.previous.count > hunk.current.count) { state = 'removed'; } else { continue; } } else { continue; } } else if (hunkLine.current?.state === 'added') { if (hunkLine.previous?.state === 'removed') { state = 'changed'; } else { state = 'added'; } } else if (hunkLine?.current.state === 'removed') { // Check if there are more deleted lines than added lines show a deleted indicator if (hunk.previous.count > hunk.current.count) { state = 'removed'; } else { continue; } } else { state = 'changed'; } let decoration = decorationsMap.get(state); if (decoration == null) { decoration = { decorationType: (state === 'added' ? Decorations.changesLineAddedAnnotation : state === 'removed' ? Decorations.changesLineDeletedAnnotation : Decorations.changesLineChangedAnnotation)!, rangesOrOptions: [{ range: range }], }; decorationsMap.set(state, decoration); } else { decoration.rangesOrOptions.push({ range: range }); } } } } sw.restart({ suffix: ' to compute recent changes annotations' }); if (decorationsMap.size) { this.setDecorations([...decorationsMap.values()]); sw.stop({ suffix: ' to apply all recent changes annotations' }); if (selection != null && context?.selection !== false) { this.editor.selection = selection; this.editor.revealRange(selection, TextEditorRevealType.InCenterIfOutsideViewport); } } this.state = { commit: commit, diffs: diffs }; this.registerHoverProvider(); return true; } registerHoverProvider() { if (!this.container.config.hovers.enabled || !this.container.config.hovers.annotations.enabled) { return; } this.hoverProviderDisposable = languages.registerHoverProvider( { pattern: this.document.uri.fsPath }, { provideHover: (document: TextDocument, position: Position, token: CancellationToken) => this.provideHover(document, position, token), }, ); } async provideHover( document: TextDocument, position: Position, _token: CancellationToken, ): Promise { if (this.state == null) return undefined; if (this.container.config.hovers.annotations.over !== 'line' && position.character !== 0) return undefined; const { commit, diffs } = this.state; for (const diff of diffs) { for (const hunk of diff.hunks) { // If we have a "mixed" diff hunk, check if we have more deleted lines than added, to include a trailing line for the deleted indicator const hasMoreDeletedLines = hunk.state === 'changed' && hunk.previous.count > hunk.current.count; if ( position.line >= hunk.current.position.start - 1 && position.line <= hunk.current.position.end - (hasMoreDeletedLines ? 0 : 1) ) { const markdown = await Hovers.localChangesMessage( commit, this.trackedDocument.uri, position.line, hunk, ); if (markdown == null) return undefined; return new Hover( markdown, document.validateRange( new Range( hunk.current.position.start - 1, 0, hunk.current.position.end - (hasMoreDeletedLines ? 0 : 1), Number.MAX_SAFE_INTEGER, ), ), ); } } } return undefined; } }