diff --git a/README.md b/README.md index cc6df90..4ebe328 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ Here are just some of the **features** that GitLens provides, - a [**_Search Commits_ view**](#search-commits-view- 'Jump to the Search Commits view') to search and explore commit histories by message, author, files, id, etc - a [**_Compare_ view**](#compare-view- 'Jump to the Compare view') to visualize comparisons between branches, tags, commits, and more - on-demand [**gutter blame**](#gutter-blame- 'Jump to the Gutter Blame') annotations, including a heatmap, for the whole file +- on-demand [**gutter changes**](#gutter-changes- 'Jump to the Gutter Changes') annotations to highlight any local changes or lines changed by the most recent commit - on-demand [**gutter heatmap**](#gutter-heatmap- 'Jump to the Gutter Heatmap') annotations to show how recently lines were changed, relative to all the other changes in the file and to now (hot vs. cold) -- on-demand [**recent changes**](#recent-changes- 'Jump to the Recent Changes') annotations to highlight lines changed by the most recent commit - many [**powerful commands**](#navigate-and-explore- 'Jump to the Navigate and Explorer') for exploring commits and histories, comparing and navigating revisions, stash access, repository status, etc - user-defined [**modes**](#modes- 'Jump to the Modes') for quickly toggling between sets of settings - and so much [**more**](#and-more- 'Jump to More') @@ -458,28 +458,28 @@ The compare view provides the following features, --- -### Gutter Heatmap [#](#gutter-heatmap- 'Gutter Heatmap') +### Gutter Changes [#](#changes- 'Gutter Changes')

- Gutter Heatmap + Gutter Changes

-- Adds an on-demand **heatmap** to the edge of the gutter to show how recently lines were changed - - The indicator's [customizable](#gutter-heatmap-settings- 'Jump to the Gutter Heatmap settings') color will either be hot or cold based on the age of the most recent change (cold after 90 days by [default](#gutter-heatmap-settings- 'Jump to the Gutter Heatmap settings')) - - The indicator's brightness ranges from bright (newer) to dim (older) based on the relative age, which is calculated from the median age of all the changes in the file - - Adds _Toggle File Heatmap Annotations_ command (`gitlens.toggleFileHeatmap`) to toggle the heatmap on and off +- Adds an on-demand, [customizable](#gutter-changes-settings- 'Jump to the Gutter Changes settings') and [themable](#themable-colors- 'Jump to the Themable Colors'), **gutter changes annotation** to highlight any local changes or lines changed by the most recent commit + - Adds _Toggle File Changes Annotations_ command (`gitlens.toggleFileChanges`) to toggle the changes annotations on and off - Press `Escape` to turn off the annotations --- -### Recent Changes [#](#recent-changes- 'Recent Changes') +### Gutter Heatmap [#](#gutter-heatmap- 'Gutter Heatmap')

- Recent Changes + Gutter Heatmap

-- Adds an on-demand, [customizable](#recent-changes-settings- 'Jump to the Recent Changes settings') and [themable](#themable-colors- 'Jump to the Themable Colors'), **recent changes annotation** to highlight lines changed by the most recent commit - - Adds _Toggle Recent File Changes Annotations_ command (`gitlens.toggleFileRecentChanges`) to toggle the recent changes annotations on and off +- Adds an on-demand **heatmap** to the edge of the gutter to show how recently lines were changed + - The indicator's [customizable](#gutter-heatmap-settings- 'Jump to the Gutter Heatmap settings') color will either be hot or cold based on the age of the most recent change (cold after 90 days by [default](#gutter-heatmap-settings- 'Jump to the Gutter Heatmap settings')) + - The indicator's brightness ranges from bright (newer) to dim (older) based on the relative age, which is calculated from the median age of all the changes in the file + - Adds _Toggle File Heatmap Annotations_ command (`gitlens.toggleFileHeatmap`) to toggle the heatmap on and off - Press `Escape` to turn off the annotations --- @@ -821,11 +821,18 @@ See also [View Settings](#view-settings- 'Jump to the View settings') | `gitlens.blame.heatmap.enabled` | Specifies whether to provide a heatmap indicator in the gutter blame annotations | | `gitlens.blame.heatmap.location` | Specifies where the heatmap indicators will be shown in the gutter blame annotations

`left` - adds a heatmap indicator on the left edge of the gutter blame annotations
`right` - adds a heatmap indicator on the right edge of the gutter blame annotations | | `gitlens.blame.highlight.enabled` | Specifies whether to highlight lines associated with the current line | -| `gitlens.blame.highlight.locations` | Specifies where the associated line highlights will be shown

`gutter` - adds a gutter glyph
`line` - adds a full-line highlight background color
`overview` - adds a decoration to the overview ruler (scroll bar) | +| `gitlens.blame.highlight.locations` | Specifies where the associated line highlights will be shown

`gutter` - adds a gutter indicator
`line` - adds a full-line highlight background color
`overview` - adds a decoration to the overview ruler (scroll bar) | | `gitlens.blame.ignoreWhitespace` | Specifies whether to ignore whitespace when comparing revisions during blame operations | | `gitlens.blame.separateLines` | Specifies whether gutter blame annotations will have line separators | | `gitlens.blame.toggleMode` | Specifies how the gutter blame annotations will be toggled

`file` - toggles each file individually
`window` - toggles the window, i.e. all files at once | +### Gutter Changes Settings [#](#gutter-changes-settings- 'Gutter Changes Settings') + +| Name | Description | +| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `gitlens.changes.locations` | Specifies where the indicators of the gutter changes annotations will be shown

`gutter` - adds a gutter indicator
`overview` - adds a decoration to the overview ruler (scroll bar) | +| `gitlens.changes.toggleMode` | Specifies how the gutter changes annotations will be toggled

`file` - toggles each file individually
`window` - toggles the window, i.e. all files at once | + ### Gutter Heatmap Settings [#](#gutter-heatmap-settings- 'Gutter Heatmap Settings') | Name | Description | @@ -835,13 +842,6 @@ See also [View Settings](#view-settings- 'Jump to the View settings') | `gitlens.heatmap.hotColor` | Specifies the base color of the gutter heatmap annotations when the most recent change is newer (hot) than the `gitlens.heatmap.ageThreshold` value | | `gitlens.heatmap.toggleMode` | Specifies how the gutter heatmap annotations will be toggled

`file` - toggles each file individually
`window` - toggles the window, i.e. all files at once | -### Recent Changes Settings [#](#recent-changes-settings- 'Recent Changes Settings') - -| Name | Description | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `gitlens.recentChanges.highlight.locations` | Specifies where the highlights of the recently changed lines will be shown

`gutter` - adds a gutter glyph
`line` - adds a full-line highlight background color
`overview` - adds a decoration to the overview ruler (scroll bar) | -| `gitlens.recentChanges.toggleMode` | Specifies how the recently changed lines annotations will be toggled

`file` - toggles each file individually
`window` - toggles the window, i.e. all files at once | - ### Git Commands Menu Settings [#](#git-commands-menu-settings- 'Git Commands Menu Settings') | Name | Description | diff --git a/package.json b/package.json index e154cf3..745ad5b 100644 --- a/package.json +++ b/package.json @@ -166,7 +166,7 @@ "overview" ], "enumDescriptions": [ - "Adds a gutter glyph", + "Adds a gutter indicator", "Adds a full-line highlight background color", "Adds a decoration to the overview ruler (scroll bar)" ] @@ -203,6 +203,43 @@ "markdownDescription": "Specifies how the gutter blame annotations will be toggled", "scope": "window" }, + "gitlens.changes.locations": { + "type": "array", + "default": [ + "gutter", + "overview" + ], + "items": { + "type": "string", + "enum": [ + "gutter", + "overview" + ], + "enumDescriptions": [ + "Adds a gutter indicator", + "Adds a decoration to the overview ruler (scroll bar)" + ] + }, + "minItems": 1, + "maxItems": 3, + "uniqueItems": true, + "markdownDescription": "Specifies where the indicators of the gutter changes annotations will be shown", + "scope": "window" + }, + "gitlens.changes.toggleMode": { + "type": "string", + "default": "file", + "enum": [ + "file", + "window" + ], + "enumDescriptions": [ + "Toggles each file individually", + "Toggles the window, i.e. all files at once" + ], + "markdownDescription": "Specifies how the gutter changes annotations will be toggled", + "scope": "window" + }, "gitlens.codeLens.authors.command": { "anyOf": [ { @@ -1140,13 +1177,13 @@ "type": "string", "enum": [ "blame", - "heatmap", - "recentChanges" + "changes", + "heatmap" ], "enumDescriptions": [ "Shows the gutter blame annotations", - "Shows the gutter heatmap annotations", - "Shows the recently changed lines annotations" + "Shows the gutter changes annotations", + "Shows the gutter heatmap annotations" ], "description": "Specifies which (if any) file annotations will be shown when this user-defined mode is active" }, @@ -1212,46 +1249,6 @@ "markdownDescription": "Specifies how much (if any) output will be sent to the GitLens output channel", "scope": "window" }, - "gitlens.recentChanges.highlight.locations": { - "type": "array", - "default": [ - "gutter", - "line", - "overview" - ], - "items": { - "type": "string", - "enum": [ - "gutter", - "line", - "overview" - ], - "enumDescriptions": [ - "Adds a gutter glyph", - "Adds a full-line highlight background color", - "Adds a decoration to the overview ruler (scroll bar)" - ] - }, - "minItems": 1, - "maxItems": 3, - "uniqueItems": true, - "markdownDescription": "Specifies where the highlights of the recently changed lines will be shown", - "scope": "window" - }, - "gitlens.recentChanges.toggleMode": { - "type": "string", - "default": "file", - "enum": [ - "file", - "window" - ], - "enumDescriptions": [ - "Toggles each file individually", - "Toggles the window, i.e. all files at once" - ], - "markdownDescription": "Specifies how the recently changed lines annotations will be toggled", - "scope": "window" - }, "gitlens.remotes": { "type": [ "array", @@ -2328,8 +2325,8 @@ } }, { - "command": "gitlens.toggleFileRecentChanges", - "title": "Toggle Recent File Changes Annotations", + "command": "gitlens.toggleFileChanges", + "title": "Toggle File Changes Annotations", "category": "GitLens", "icon": { "dark": "images/dark/icon-git.svg", @@ -3566,7 +3563,7 @@ "when": "gitlens:activeFileStatus =~ /blameable/" }, { - "command": "gitlens.toggleFileRecentChanges", + "command": "gitlens.toggleFileChanges", "when": "gitlens:activeFileStatus =~ /blameable/" }, { diff --git a/src/annotations/annotationProvider.ts b/src/annotations/annotationProvider.ts index cf9db29..5db824b 100644 --- a/src/annotations/annotationProvider.ts +++ b/src/annotations/annotationProvider.ts @@ -12,7 +12,7 @@ import { } from 'vscode'; import { FileAnnotationType } from '../configuration'; import { CommandContext, setCommandContext } from '../constants'; -import { Functions } from '../system'; +import { Logger } from '../logger'; import { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; export enum AnnotationStatus { @@ -32,15 +32,12 @@ export abstract class AnnotationProviderBase implements Disposable { document: TextDocument; status: AnnotationStatus | undefined; - protected decorations: DecorationOptions[] | undefined; + private decorations: + | { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] | DecorationOptions[] }[] + | undefined; protected disposable: Disposable; - constructor( - public editor: TextEditor, - protected readonly trackedDocument: TrackedDocument, - protected decoration: TextEditorDecorationType | undefined, - protected highlightDecoration: TextEditorDecorationType | undefined, - ) { + constructor(public editor: TextEditor, protected readonly trackedDocument: TrackedDocument) { this.correlationKey = AnnotationProviderBase.getCorrelationKey(this.editor); this.document = this.editor.document; @@ -71,65 +68,19 @@ export abstract class AnnotationProviderBase implements Disposable { return this.editor.document.uri; } - protected additionalDecorations: { decoration: TextEditorDecorationType; ranges: Range[] }[] | undefined; - clear() { this.status = undefined; if (this.editor == null) return; - if (this.decoration != null) { - try { - this.editor.setDecorations(this.decoration, []); - } catch {} - } - - if (this.additionalDecorations?.length) { - for (const d of this.additionalDecorations) { + if (this.decorations?.length) { + for (const d of this.decorations) { try { - this.editor.setDecorations(d.decoration, []); + this.editor.setDecorations(d.decorationType, []); } catch {} } - this.additionalDecorations = undefined; - } - - if (this.highlightDecoration != null) { - try { - this.editor.setDecorations(this.highlightDecoration, []); - } catch {} - } - } - - private _resetDebounced: - | ((changes?: { - decoration: TextEditorDecorationType; - highlightDecoration: TextEditorDecorationType | undefined; - }) => void) - | undefined; - - reset(changes?: { - decoration: TextEditorDecorationType; - highlightDecoration: TextEditorDecorationType | undefined; - }) { - if (this._resetDebounced == null) { - this._resetDebounced = Functions.debounce(this.onReset.bind(this), 250); - } - - this._resetDebounced(changes); - } - - async onReset(changes?: { - decoration: TextEditorDecorationType; - highlightDecoration: TextEditorDecorationType | undefined; - }) { - if (changes != null) { - this.clear(); - - this.decoration = changes.decoration; - this.highlightDecoration = changes.highlightDecoration; + this.decorations = undefined; } - - await this.provideAnnotation(this.editor == null ? undefined : this.editor.selection.active.line); } async restore(editor: TextEditor) { @@ -146,13 +97,9 @@ export abstract class AnnotationProviderBase implements Disposable { this.correlationKey = AnnotationProviderBase.getCorrelationKey(editor); this.document = editor.document; - if (this.decoration != null && this.decorations?.length) { - this.editor.setDecorations(this.decoration, this.decorations); - } - - if (this.additionalDecorations?.length) { - for (const d of this.additionalDecorations) { - this.editor.setDecorations(d.decoration, d.ranges); + if (this.decorations?.length) { + for (const d of this.decorations) { + this.editor.setDecorations(d.decorationType, d.rangesOrOptions); } } @@ -164,16 +111,37 @@ export abstract class AnnotationProviderBase implements Disposable { async provideAnnotation(shaOrLine?: string | number): Promise { this.status = AnnotationStatus.Computing; - if (await this.onProvideAnnotation(shaOrLine)) { - this.status = AnnotationStatus.Computed; - return true; + try { + if (await this.onProvideAnnotation(shaOrLine)) { + this.status = AnnotationStatus.Computed; + return true; + } + } catch (ex) { + Logger.error(ex); } this.status = undefined; return false; } - abstract onProvideAnnotation(shaOrLine?: string | number): Promise; + protected abstract onProvideAnnotation(shaOrLine?: string | number): Promise; + abstract selection(shaOrLine?: string | number): Promise; + + protected setDecorations( + decorations: { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] | DecorationOptions[] }[], + ) { + if (this.decorations?.length) { + this.clear(); + } + + this.decorations = decorations; + if (this.decorations?.length) { + for (const d of this.decorations) { + this.editor.setDecorations(d.decorationType, d.rangesOrOptions); + } + } + } + abstract validate(): Promise; } diff --git a/src/annotations/annotations.ts b/src/annotations/annotations.ts index 82ad855..931b3e9 100644 --- a/src/annotations/annotations.ts +++ b/src/annotations/annotations.ts @@ -109,7 +109,7 @@ export class Annotations { date: Date, heatmap: ComputedHeatmap, range: Range, - map: Map, + map: Map, ) { const [r, g, b, a] = this.getHeatmapColor(date, heatmap); @@ -117,7 +117,7 @@ export class Annotations { let colorDecoration = map.get(key); if (colorDecoration == null) { colorDecoration = { - decoration: window.createTextEditorDecorationType({ + decorationType: window.createTextEditorDecorationType({ gutterIconPath: Uri.parse( `data:image/svg+xml,${encodeURIComponent( ``, @@ -125,14 +125,14 @@ export class Annotations { ), gutterIconSize: 'contain', }), - ranges: [range], + rangesOrOptions: [range], }; map.set(key, colorDecoration); } else { - colorDecoration.ranges.push(range); + colorDecoration.rangesOrOptions.push(range); } - return colorDecoration.decoration; + return colorDecoration.decorationType; } static gutter( diff --git a/src/annotations/blameAnnotationProvider.ts b/src/annotations/blameAnnotationProvider.ts index 9462be8..ebd6fcd 100644 --- a/src/annotations/blameAnnotationProvider.ts +++ b/src/annotations/blameAnnotationProvider.ts @@ -4,11 +4,11 @@ import { Disposable, Hover, languages, + MarkdownString, Position, Range, TextDocument, TextEditor, - TextEditorDecorationType, } from 'vscode'; import { AnnotationProviderBase } from './annotationProvider'; import { ComputedHeatmap, getHeatmapColors } from './annotations'; @@ -16,26 +16,19 @@ import { Container } from '../container'; import { GitBlame, GitBlameCommit, GitCommit } from '../git/git'; import { GitUri } from '../git/gitUri'; import { Hovers } from '../hovers/hovers'; -import { Arrays, Iterables, log } from '../system'; +import { log } from '../system'; import { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase { - protected _blame: Promise; - protected _hoverProviderDisposable: Disposable | undefined; - protected readonly _uri: GitUri; - - constructor( - editor: TextEditor, - trackedDocument: TrackedDocument, - decoration: TextEditorDecorationType | undefined, - highlightDecoration: TextEditorDecorationType | undefined, - ) { - super(editor, trackedDocument, decoration, highlightDecoration); - - this._uri = trackedDocument.uri; - this._blame = editor.document.isDirty - ? Container.git.getBlameForFileContents(this._uri, editor.document.getText()) - : Container.git.getBlameForFile(this._uri); + protected blame: Promise; + protected hoverProviderDisposable: Disposable | undefined; + + constructor(editor: TextEditor, trackedDocument: TrackedDocument) { + super(editor, trackedDocument); + + this.blame = editor.document.isDirty + ? Container.git.getBlameForFileContents(this.trackedDocument.uri, editor.document.getText()) + : Container.git.getBlameForFile(this.trackedDocument.uri); if (editor.document.isDirty) { trackedDocument.setForceDirtyStateChangeOnNextDocumentChange(); @@ -43,69 +36,20 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase } clear() { - if (this._hoverProviderDisposable != null) { - this._hoverProviderDisposable.dispose(); - this._hoverProviderDisposable = undefined; + if (this.hoverProviderDisposable != null) { + this.hoverProviderDisposable.dispose(); + this.hoverProviderDisposable = undefined; } super.clear(); } - onReset(changes?: { - decoration: TextEditorDecorationType; - highlightDecoration: TextEditorDecorationType | undefined; - }) { - if (this.editor != null) { - this._blame = this.editor.document.isDirty - ? Container.git.getBlameForFileContents(this._uri, this.editor.document.getText()) - : Container.git.getBlameForFile(this._uri); - } - - return super.onReset(changes); - } - - @log({ args: false }) - async selection(shaOrLine?: string | number, blame?: GitBlame) { - if (!this.highlightDecoration) return; - - if (blame == null) { - blame = await this._blame; - if (!blame || !blame.lines.length) return; - } - - let sha: string | undefined = undefined; - if (typeof shaOrLine === 'string') { - sha = shaOrLine; - } else if (typeof shaOrLine === 'number') { - if (shaOrLine >= 0) { - const commitLine = blame.lines[shaOrLine]; - sha = commitLine?.sha; - } - } else { - sha = Iterables.first(blame.commits.values()).sha; - } - - if (!sha) { - this.editor.setDecorations(this.highlightDecoration, []); - return; - } - - const highlightDecorationRanges = Arrays.filterMap(blame.lines, l => - l.sha === sha - ? // editor lines are 0-based - this.editor.document.validateRange(new Range(l.line - 1, 0, l.line - 1, Number.MAX_SAFE_INTEGER)) - : undefined, - ); - - this.editor.setDecorations(this.highlightDecoration, highlightDecorationRanges); - } - async validate(): Promise { - const blame = await this._blame; + const blame = await this.blame; return blame != null && blame.lines.length !== 0; } protected async getBlame(): Promise { - const blame = await this._blame; + const blame = await this.blame; if (blame == null || blame.lines.length === 0) return undefined; return blame; @@ -190,39 +134,46 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase return; } - const subscriptions: Disposable[] = []; - if (providers.changes) { - subscriptions.push( - languages.registerHoverProvider( - { pattern: this.document.uri.fsPath }, - { - provideHover: this.provideChangesHover.bind(this), - }, - ), - ); - } - if (providers.details) { - subscriptions.push( - languages.registerHoverProvider( - { pattern: this.document.uri.fsPath }, - { - provideHover: this.provideDetailsHover.bind(this), - }, - ), - ); - } - - this._hoverProviderDisposable = Disposable.from(...subscriptions); + this.hoverProviderDisposable = languages.registerHoverProvider( + { pattern: this.document.uri.fsPath }, + { + provideHover: (document, position, token) => this.provideHover(providers, document, position, token), + }, + ); } - async provideDetailsHover( + async provideHover( + providers: { details: boolean; changes: boolean }, document: TextDocument, position: Position, _token: CancellationToken, ): Promise { - const commit = await this.getCommitForHover(position); + if (Container.config.hovers.annotations.over !== 'line' && position.character !== 0) return undefined; + + const blame = await this.getBlame(); + if (blame == null) return undefined; + + const line = blame.lines[position.line]; + + const commit = blame.commits.get(line.sha); if (commit == null) return undefined; + const messages = ( + await Promise.all([ + providers.details ? this.getDetailsHoverMessage(commit, document) : undefined, + providers.changes + ? Hovers.changesMessage(commit, await GitUri.fromUri(document.uri), position.line) + : undefined, + ]) + ).filter(Boolean) as MarkdownString[]; + + return new Hover( + messages, + document.validateRange(new Range(position.line, 0, position.line, Number.MAX_SAFE_INTEGER)), + ); + } + + private async getDetailsHoverMessage(commit: GitBlameCommit, document: TextDocument) { // Get the full commit message -- since blame only returns the summary let logCommit: GitCommit | undefined = undefined; if (!commit.isUncommitted) { @@ -241,46 +192,13 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase const commitLine = commit.lines.find(l => l.line === line) ?? commit.lines[0]; editorLine = commitLine.originalLine - 1; - const message = await Hovers.detailsMessage( + return Hovers.detailsMessage( logCommit ?? commit, await GitUri.fromUri(document.uri), editorLine, Container.config.defaultDateFormat, - this.annotationType, - ); - return new Hover( - message, - document.validateRange(new Range(position.line, 0, position.line, Number.MAX_SAFE_INTEGER)), ); } - - async provideChangesHover( - document: TextDocument, - position: Position, - _token: CancellationToken, - ): Promise { - const commit = await this.getCommitForHover(position); - if (commit == null) return undefined; - - const message = await Hovers.changesMessage(commit, await GitUri.fromUri(document.uri), position.line); - if (message == null) return undefined; - - return new Hover( - message, - document.validateRange(new Range(position.line, 0, position.line, Number.MAX_SAFE_INTEGER)), - ); - } - - private async getCommitForHover(position: Position): Promise { - if (Container.config.hovers.annotations.over !== 'line' && position.character !== 0) return undefined; - - const blame = await this.getBlame(); - if (blame == null) return undefined; - - const line = blame.lines[position.line]; - - return blame.commits.get(line.sha); - } } function getRelativeAgeLookupTable(dates: Date[]) { diff --git a/src/annotations/fileAnnotationController.ts b/src/annotations/fileAnnotationController.ts index 4aec22d..24c676a 100644 --- a/src/annotations/fileAnnotationController.ts +++ b/src/annotations/fileAnnotationController.ts @@ -18,9 +18,19 @@ import { window, workspace, } from 'vscode'; -import { AnnotationsToggleMode, configuration, FileAnnotationType, HighlightLocations } from '../configuration'; +import { AnnotationProviderBase, AnnotationStatus, TextEditorCorrelationKey } from './annotationProvider'; +import { + AnnotationsToggleMode, + BlameHighlightLocations, + ChangesLocations, + configuration, + FileAnnotationType, +} from '../configuration'; import { CommandContext, isTextEditor, setCommandContext } from '../constants'; import { Container } from '../container'; +import { GutterBlameAnnotationProvider } from './gutterBlameAnnotationProvider'; +import { GutterChangesAnnotationProvider } from './gutterChangesAnnotationProvider'; +import { GutterHeatmapBlameAnnotationProvider } from './gutterHeatmapBlameAnnotationProvider'; import { KeyboardScope } from '../keyboard'; import { Logger } from '../logger'; import { Functions, Iterables } from '../system'; @@ -29,10 +39,6 @@ import { DocumentDirtyStateChangeEvent, GitDocumentState, } from '../trackers/gitDocumentTracker'; -import { AnnotationProviderBase, AnnotationStatus, TextEditorCorrelationKey } from './annotationProvider'; -import { GutterBlameAnnotationProvider } from './gutterBlameAnnotationProvider'; -import { HeatmapBlameAnnotationProvider } from './heatmapBlameAnnotationProvider'; -import { RecentChangesAnnotationProvider } from './recentChangesAnnotationProvider'; export enum AnnotationClearReason { User = 'User', @@ -44,15 +50,14 @@ export enum AnnotationClearReason { } export const Decorations = { - blameAnnotation: window.createTextEditorDecorationType({ + gutterBlameAnnotation: window.createTextEditorDecorationType({ rangeBehavior: DecorationRangeBehavior.ClosedOpen, textDecoration: 'none', }), - blameHighlight: undefined as TextEditorDecorationType | undefined, - heatmapAnnotation: undefined as TextEditorDecorationType | undefined, - heatmapHighlight: undefined as TextEditorDecorationType | undefined, - recentChangesAnnotation: undefined as TextEditorDecorationType | undefined, - recentChangesHighlight: undefined as TextEditorDecorationType | undefined, + gutterBlameHighlight: undefined as TextEditorDecorationType | undefined, + changesLineChangedAnnotation: undefined as TextEditorDecorationType | undefined, + changesLineAddedAnnotation: undefined as TextEditorDecorationType | undefined, + changesLineDeletedAnnotation: undefined as TextEditorDecorationType | undefined, }; export class FileAnnotationController implements Disposable { @@ -79,8 +84,11 @@ export class FileAnnotationController implements Disposable { dispose() { void this.clearAll(); - Decorations.blameAnnotation?.dispose(); - Decorations.blameHighlight?.dispose(); + Decorations.gutterBlameAnnotation?.dispose(); + Decorations.gutterBlameHighlight?.dispose(); + Decorations.changesLineChangedAnnotation?.dispose(); + Decorations.changesLineAddedAnnotation?.dispose(); + Decorations.changesLineDeletedAnnotation?.dispose(); this._annotationsDisposable?.dispose(); this._disposable?.dispose(); @@ -90,15 +98,17 @@ export class FileAnnotationController implements Disposable { const cfg = Container.config; if (configuration.changed(e, 'blame', 'highlight')) { - Decorations.blameHighlight?.dispose(); - Decorations.blameHighlight = undefined; + Decorations.gutterBlameHighlight?.dispose(); + Decorations.gutterBlameHighlight = undefined; + + const highlight = cfg.blame.highlight; - const cfgHighlight = cfg.blame.highlight; + if (highlight.enabled) { + const { locations } = highlight; - if (cfgHighlight.enabled) { // TODO@eamodio: Read from the theme color when the API exists const gutterHighlightColor = '#00bcf2'; // new ThemeColor('gitlens.lineHighlightOverviewRulerColor') - const gutterHighlightUri = cfgHighlight.locations.includes(HighlightLocations.Gutter) + const gutterHighlightUri = locations.includes(BlameHighlightLocations.Gutter) ? Uri.parse( `data:image/svg+xml,${encodeURIComponent( ``, @@ -106,46 +116,70 @@ export class FileAnnotationController implements Disposable { ) : undefined; - Decorations.blameHighlight = window.createTextEditorDecorationType({ + Decorations.gutterBlameHighlight = window.createTextEditorDecorationType({ gutterIconPath: gutterHighlightUri, gutterIconSize: 'contain', isWholeLine: true, overviewRulerLane: OverviewRulerLane.Right, - backgroundColor: cfgHighlight.locations.includes(HighlightLocations.Line) + backgroundColor: locations.includes(BlameHighlightLocations.Line) ? new ThemeColor('gitlens.lineHighlightBackgroundColor') : undefined, - overviewRulerColor: cfgHighlight.locations.includes(HighlightLocations.Overview) + overviewRulerColor: locations.includes(BlameHighlightLocations.Overview) ? new ThemeColor('gitlens.lineHighlightOverviewRulerColor') : undefined, }); } } - if (configuration.changed(e, 'recentChanges', 'highlight')) { - Decorations.recentChangesAnnotation?.dispose(); + if (configuration.changed(e, 'changes', 'locations')) { + Decorations.changesLineAddedAnnotation?.dispose(); + Decorations.changesLineChangedAnnotation?.dispose(); + Decorations.changesLineDeletedAnnotation?.dispose(); - const cfgHighlight = cfg.recentChanges.highlight; + const { locations } = cfg.changes; - // TODO@eamodio: Read from the theme color when the API exists - const gutterHighlightColor = '#00bcf2'; // new ThemeColor('gitlens.lineHighlightOverviewRulerColor') - const gutterHighlightUri = cfgHighlight.locations.includes(HighlightLocations.Gutter) - ? Uri.parse( - `data:image/svg+xml,${encodeURIComponent( - ``, - )}`, - ) - : undefined; + Decorations.changesLineAddedAnnotation = window.createTextEditorDecorationType({ + gutterIconPath: locations.includes(ChangesLocations.Gutter) + ? Uri.parse( + `data:image/svg+xml,${encodeURIComponent( + "", + )}`, + ) + : undefined, + gutterIconSize: 'contain', + overviewRulerLane: OverviewRulerLane.Left, + overviewRulerColor: locations.includes(ChangesLocations.Overview) + ? new ThemeColor('editorOverviewRuler.addedForeground') + : undefined, + }); - Decorations.recentChangesAnnotation = window.createTextEditorDecorationType({ - gutterIconPath: gutterHighlightUri, + Decorations.changesLineChangedAnnotation = window.createTextEditorDecorationType({ + gutterIconPath: locations.includes(ChangesLocations.Gutter) + ? Uri.parse( + `data:image/svg+xml,${encodeURIComponent( + "", + )}`, + ) + : undefined, gutterIconSize: 'contain', - isWholeLine: true, - overviewRulerLane: OverviewRulerLane.Right, - backgroundColor: cfgHighlight.locations.includes(HighlightLocations.Line) - ? new ThemeColor('gitlens.lineHighlightBackgroundColor') + overviewRulerLane: OverviewRulerLane.Left, + overviewRulerColor: locations.includes(ChangesLocations.Overview) + ? new ThemeColor('editorOverviewRuler.modifiedForeground') : undefined, - overviewRulerColor: cfgHighlight.locations.includes(HighlightLocations.Overview) - ? new ThemeColor('gitlens.lineHighlightOverviewRulerColor') + }); + + Decorations.changesLineDeletedAnnotation = window.createTextEditorDecorationType({ + gutterIconPath: locations.includes(ChangesLocations.Gutter) + ? Uri.parse( + `data:image/svg+xml,${encodeURIComponent( + "", + )}`, + ) + : undefined, + gutterIconSize: 'contain', + overviewRulerLane: OverviewRulerLane.Left, + overviewRulerColor: locations.includes(ChangesLocations.Overview) + ? new ThemeColor('editorOverviewRuler.deletedForeground') : undefined, }); } @@ -159,16 +193,16 @@ export class FileAnnotationController implements Disposable { } } - if (configuration.changed(e, 'heatmap', 'toggleMode')) { - this._toggleModes.set(FileAnnotationType.Heatmap, cfg.heatmap.toggleMode); - if (!initializing && cfg.heatmap.toggleMode === AnnotationsToggleMode.File) { + if (configuration.changed(e, 'changes', 'toggleMode')) { + this._toggleModes.set(FileAnnotationType.Changes, cfg.changes.toggleMode); + if (!initializing && cfg.changes.toggleMode === AnnotationsToggleMode.File) { void this.clearAll(); } } - if (configuration.changed(e, 'recentChanges', 'toggleMode')) { - this._toggleModes.set(FileAnnotationType.RecentChanges, cfg.recentChanges.toggleMode); - if (!initializing && cfg.recentChanges.toggleMode === AnnotationsToggleMode.File) { + if (configuration.changed(e, 'heatmap', 'toggleMode')) { + this._toggleModes.set(FileAnnotationType.Heatmap, cfg.heatmap.toggleMode); + if (!initializing && cfg.heatmap.toggleMode === AnnotationsToggleMode.File) { void this.clearAll(); } } @@ -177,7 +211,7 @@ export class FileAnnotationController implements Disposable { if ( configuration.changed(e, 'blame') || - configuration.changed(e, 'recentChanges') || + configuration.changed(e, 'changes') || configuration.changed(e, 'heatmap') || configuration.changed(e, 'hovers') ) { @@ -185,19 +219,7 @@ export class FileAnnotationController implements Disposable { for (const provider of this._annotationProviders.values()) { if (provider == null) continue; - if (provider.annotationType === FileAnnotationType.RecentChanges) { - provider.reset({ - decoration: Decorations.recentChangesAnnotation!, - highlightDecoration: Decorations.recentChangesHighlight, - }); - } else if (provider.annotationType === FileAnnotationType.Blame) { - provider.reset({ - decoration: Decorations.blameAnnotation, - highlightDecoration: Decorations.blameHighlight, - }); - } else { - void this.show(provider.editor, FileAnnotationType.Heatmap); - } + void this.show(provider.editor, provider.annotationType ?? FileAnnotationType.Blame); } } } @@ -330,7 +352,7 @@ export class FileAnnotationController implements Disposable { let first = this._annotationType == null; const reset = (!first && this._annotationType !== type) || - (this._annotationType === FileAnnotationType.RecentChanges && typeof shaOrLine === 'string'); + (this._annotationType === FileAnnotationType.Changes && typeof shaOrLine === 'string'); this._annotationType = type; @@ -393,10 +415,7 @@ export class FileAnnotationController implements Disposable { ): Promise { if (editor != null) { const trackedDocument = await Container.tracker.getOrAdd(editor.document); - if ( - (type === FileAnnotationType.RecentChanges && !trackedDocument.isTracked) || - !trackedDocument.isBlameable - ) { + if ((type === FileAnnotationType.Changes && !trackedDocument.isTracked) || !trackedDocument.isBlameable) { return false; } } @@ -405,8 +424,7 @@ export class FileAnnotationController implements Disposable { if (provider == null) return this.show(editor, type, shaOrLine); const reopen = - provider.annotationType !== type || - (type === FileAnnotationType.RecentChanges && typeof shaOrLine === 'string'); + provider.annotationType !== type || (type === FileAnnotationType.Changes && typeof shaOrLine === 'string'); if (on === true && !reopen) return true; if (this.isInWindowToggle()) { @@ -482,12 +500,12 @@ export class FileAnnotationController implements Disposable { annotationsLabel = 'blame annotations'; break; - case FileAnnotationType.Heatmap: - annotationsLabel = 'heatmap annotations'; + case FileAnnotationType.Changes: + annotationsLabel = 'changes annotations'; break; - case FileAnnotationType.RecentChanges: - annotationsLabel = 'recent changes annotations'; + case FileAnnotationType.Heatmap: + annotationsLabel = 'heatmap annotations'; break; } @@ -504,30 +522,15 @@ export class FileAnnotationController implements Disposable { let provider: AnnotationProviderBase | undefined = undefined; switch (type) { case FileAnnotationType.Blame: - provider = new GutterBlameAnnotationProvider( - editor, - trackedDocument, - Decorations.blameAnnotation, - Decorations.blameHighlight, - ); + provider = new GutterBlameAnnotationProvider(editor, trackedDocument); break; - case FileAnnotationType.Heatmap: - provider = new HeatmapBlameAnnotationProvider( - editor, - trackedDocument, - Decorations.heatmapAnnotation, - Decorations.heatmapHighlight, - ); + case FileAnnotationType.Changes: + provider = new GutterChangesAnnotationProvider(editor, trackedDocument); break; - case FileAnnotationType.RecentChanges: - provider = new RecentChangesAnnotationProvider( - editor, - trackedDocument, - Decorations.recentChangesAnnotation!, - Decorations.recentChangesHighlight, - ); + case FileAnnotationType.Heatmap: + provider = new GutterHeatmapBlameAnnotationProvider(editor, trackedDocument); break; } if (provider == null || !(await provider.validate())) return undefined; @@ -536,7 +539,7 @@ export class FileAnnotationController implements Disposable { await this.clearCore(currentProvider.correlationKey, AnnotationClearReason.User); } - if (!this._annotationsDisposable && this._annotationProviders.size === 0) { + if (this._annotationsDisposable == null && this._annotationProviders.size === 0) { Logger.log('Add listener registrations for annotations'); this._annotationsDisposable = Disposable.from( @@ -555,6 +558,8 @@ export class FileAnnotationController implements Disposable { return provider; } + await this.clearCore(provider.correlationKey, AnnotationClearReason.Disposing); + return undefined; } } diff --git a/src/annotations/gutterBlameAnnotationProvider.ts b/src/annotations/gutterBlameAnnotationProvider.ts index 0a629db..5836fb6 100644 --- a/src/annotations/gutterBlameAnnotationProvider.ts +++ b/src/annotations/gutterBlameAnnotationProvider.ts @@ -1,15 +1,26 @@ 'use strict'; import { DecorationOptions, Range, ThemableDecorationAttachmentRenderOptions } from 'vscode'; +import { Annotations } from './annotations'; +import { BlameAnnotationProviderBase } from './blameAnnotationProvider'; import { FileAnnotationType, GravatarDefaultStyle } from '../configuration'; import { GlyphChars } from '../constants'; import { Container } from '../container'; -import { CommitFormatOptions, CommitFormatter, GitBlameCommit } from '../git/git'; +import { Decorations } from './fileAnnotationController'; +import { CommitFormatOptions, CommitFormatter, GitBlame, GitBlameCommit } from '../git/git'; import { Logger } from '../logger'; -import { log, Strings } from '../system'; -import { Annotations } from './annotations'; -import { BlameAnnotationProviderBase } from './blameAnnotationProvider'; +import { Arrays, Iterables, log, Strings } from '../system'; export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { + clear() { + super.clear(); + + if (Decorations.gutterBlameHighlight != null) { + try { + this.editor.setDecorations(Decorations.gutterBlameHighlight, []); + } catch {} + } + } + @log() async onProvideAnnotation(_shaOrLine?: string | number, _type?: FileAnnotationType): Promise { const cc = Logger.getCorrelationContext(); @@ -53,7 +64,7 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { options, ); - this.decorations = []; + const decorationOptions = []; const decorationsMap = new Map(); const avatarDecorationsMap = avatars ? new Map() : undefined; @@ -99,7 +110,7 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { gutter.range = new Range(editorLine, 0, editorLine, 0); - this.decorations.push(gutter); + decorationOptions.push(gutter); continue; } @@ -117,7 +128,7 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { range: new Range(editorLine, 0, editorLine, 0), }; - this.decorations.push(gutter); + decorationOptions.push(gutter); continue; } @@ -130,7 +141,7 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { gutter.range = new Range(editorLine, 0, editorLine, 0); - this.decorations.push(gutter); + decorationOptions.push(gutter); if (avatars && commit.email != null) { this.applyAvatarDecoration(commit, gutter, gravatarDefault, avatarDecorationsMap!); @@ -141,10 +152,12 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { Logger.log(cc, `${Strings.getDurationMilliseconds(start)} ms to compute gutter blame annotations`); - if (this.decoration != null && this.decorations.length) { + if (decorationOptions.length) { start = process.hrtime(); - this.editor.setDecorations(this.decoration, this.decorations); + this.setDecorations([ + { decorationType: Decorations.gutterBlameAnnotation, rangesOrOptions: decorationOptions }, + ]); Logger.log(cc, `${Strings.getDurationMilliseconds(start)} ms to apply all gutter blame annotations`); } @@ -153,7 +166,43 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase { return true; } - applyAvatarDecoration( + @log({ args: false }) + async selection(shaOrLine?: string | number, blame?: GitBlame) { + if (Decorations.gutterBlameHighlight == null) return; + + if (blame == null) { + blame = await this.blame; + if (!blame?.lines.length) return; + } + + let sha: string | undefined = undefined; + if (typeof shaOrLine === 'string') { + sha = shaOrLine; + } else if (typeof shaOrLine === 'number') { + if (shaOrLine >= 0) { + const commitLine = blame.lines[shaOrLine]; + sha = commitLine?.sha; + } + } else { + sha = Iterables.first(blame.commits.values()).sha; + } + + if (!sha) { + this.editor.setDecorations(Decorations.gutterBlameHighlight, []); + return; + } + + const highlightDecorationRanges = Arrays.filterMap(blame.lines, l => + l.sha === sha + ? // editor lines are 0-based + this.editor.document.validateRange(new Range(l.line - 1, 0, l.line - 1, Number.MAX_SAFE_INTEGER)) + : undefined, + ); + + this.editor.setDecorations(Decorations.gutterBlameHighlight, highlightDecorationRanges); + } + + private applyAvatarDecoration( commit: GitBlameCommit, gutter: DecorationOptions, gravatarDefault: GravatarDefaultStyle, diff --git a/src/annotations/gutterChangesAnnotationProvider.ts b/src/annotations/gutterChangesAnnotationProvider.ts new file mode 100644 index 0000000..aaa63cd --- /dev/null +++ b/src/annotations/gutterChangesAnnotationProvider.ts @@ -0,0 +1,283 @@ +'use strict'; +import { + CancellationToken, + DecorationOptions, + Disposable, + Hover, + languages, + Position, + Range, + Selection, + TextDocument, + TextEditor, + TextEditorDecorationType, + TextEditorRevealType, +} from 'vscode'; +import { AnnotationProviderBase } from './annotationProvider'; +import { FileAnnotationType } from '../configuration'; +import { Container } from '../container'; +import { Decorations } from './fileAnnotationController'; +import { GitDiff, GitLogCommit } from '../git/git'; +import { Hovers } from '../hovers/hovers'; +import { Logger } from '../logger'; +import { log, Strings } from '../system'; +import { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; + +export class GutterChangesAnnotationProvider extends AnnotationProviderBase { + private state: { commit: GitLogCommit | undefined; diffs: GitDiff[] } | undefined; + private hoverProviderDisposable: Disposable | undefined; + + constructor(editor: TextEditor, trackedDocument: TrackedDocument) { + super(editor, trackedDocument); + } + + clear() { + this.state = undefined; + if (this.hoverProviderDisposable != null) { + this.hoverProviderDisposable.dispose(); + this.hoverProviderDisposable = undefined; + } + super.clear(); + } + + selection(_shaOrLine?: string | number): Promise { + return Promise.resolve(); + } + + validate(): Promise { + return Promise.resolve(true); + } + + @log() + async onProvideAnnotation(shaOrLine?: string | number): Promise { + const cc = Logger.getCorrelationContext(); + + this.annotationType = FileAnnotationType.Changes; + + let ref1 = this.trackedDocument.uri.sha; + let ref2; + if (typeof shaOrLine === 'string') { + if (shaOrLine !== this.trackedDocument.uri.sha) { + ref2 = `${shaOrLine}^`; + } + } + + let commit: GitLogCommit | undefined; + + let localChanges = ref1 == null && ref2 == null; + if (localChanges) { + let ref = await Container.git.getOldestUnpushedRefForFile( + this.trackedDocument.uri.repoPath!, + this.trackedDocument.uri.fsPath, + ); + if (ref != null) { + ref = `${ref}^`; + commit = await Container.git.getCommitForFile( + this.trackedDocument.uri.repoPath, + this.trackedDocument.uri.fsPath, + { ref: ref }, + ); + if (commit != null) { + if (ref2 != null) { + ref2 = ref; + } else { + ref1 = ref; + ref2 = ''; + } + } else { + localChanges = false; + } + } else { + const status = await Container.git.getStatusForFile( + this.trackedDocument.uri.repoPath!, + this.trackedDocument.uri.fsPath, + ); + const commits = await status?.toPsuedoCommits(); + if (commits?.length) { + commit = await Container.git.getCommitForFile( + this.trackedDocument.uri.repoPath, + this.trackedDocument.uri.fsPath, + ); + ref1 = 'HEAD'; + } else if (this.trackedDocument.dirty) { + ref1 = 'HEAD'; + } else { + localChanges = false; + } + } + } + + if (!localChanges) { + commit = await Container.git.getCommitForFile( + this.trackedDocument.uri.repoPath, + this.trackedDocument.uri.fsPath, + { + 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 + ? [ + Container.git.getDiffForFileContents( + this.trackedDocument.uri, + ref1!, + this.editor.document.getText(), + ), + Container.git.getDiffForFile(this.trackedDocument.uri, ref1, ref2), + ] + : [Container.git.getDiffForFile(this.trackedDocument.uri, ref1, ref2)], + ) + ).filter(Boolean) as GitDiff[]; + if (!diffs?.length) return false; + + let start = process.hrtime(); + + const decorationsMap = new Map< + string, + { decorationType: TextEditorDecorationType; rangesOrOptions: DecorationOptions[] } + >(); + + let selection: Selection | undefined; + + for (const diff of diffs) { + for (const hunk of diff.hunks) { + // 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; + + 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 }); + } + } + } + } + + Logger.log(cc, `${Strings.getDurationMilliseconds(start)} ms to compute recent changes annotations`); + + if (decorationsMap.size) { + start = process.hrtime(); + + this.setDecorations([...decorationsMap.values()]); + + Logger.log(cc, `${Strings.getDurationMilliseconds(start)} ms to apply recent changes annotations`); + + if (selection != null) { + this.editor.selection = selection; + this.editor.revealRange(selection, TextEditorRevealType.InCenterIfOutsideViewport); + } + } + + this.state = { commit: commit, diffs: diffs }; + this.registerHoverProvider(); + return true; + } + + registerHoverProvider() { + if (!Container.config.hovers.enabled || !Container.config.hovers.annotations.enabled) { + return; + } + + this.hoverProviderDisposable = languages.registerHoverProvider( + { pattern: this.document.uri.fsPath }, + { + provideHover: (document, position, token) => this.provideHover(document, position, token), + }, + ); + } + + provideHover(document: TextDocument, position: Position, _token: CancellationToken): Hover | undefined { + if (this.state == null) return undefined; + if (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) + ) { + return new Hover( + Hovers.localChangesMessage(commit, this.trackedDocument.uri, position.line, hunk), + document.validateRange( + new Range( + hunk.current.position.start - 1, + 0, + hunk.current.position.end - (hasMoreDeletedLines ? 0 : 1), + Number.MAX_SAFE_INTEGER, + ), + ), + ); + } + } + } + + return undefined; + } +} diff --git a/src/annotations/heatmapBlameAnnotationProvider.ts b/src/annotations/gutterHeatmapBlameAnnotationProvider.ts similarity index 74% rename from src/annotations/heatmapBlameAnnotationProvider.ts rename to src/annotations/gutterHeatmapBlameAnnotationProvider.ts index 71f19cc..a6cb7c5 100644 --- a/src/annotations/heatmapBlameAnnotationProvider.ts +++ b/src/annotations/gutterHeatmapBlameAnnotationProvider.ts @@ -1,14 +1,13 @@ 'use strict'; import { Range, TextEditorDecorationType } from 'vscode'; +import { Annotations } from './annotations'; +import { BlameAnnotationProviderBase } from './blameAnnotationProvider'; import { FileAnnotationType } from '../configuration'; -import { Container } from '../container'; import { GitBlameCommit } from '../git/git'; import { Logger } from '../logger'; import { log, Strings } from '../system'; -import { Annotations } from './annotations'; -import { BlameAnnotationProviderBase } from './blameAnnotationProvider'; -export class HeatmapBlameAnnotationProvider extends BlameAnnotationProviderBase { +export class GutterHeatmapBlameAnnotationProvider extends BlameAnnotationProviderBase { @log() async onProvideAnnotation(_shaOrLine?: string | number, _type?: FileAnnotationType): Promise { const cc = Logger.getCorrelationContext(); @@ -20,7 +19,10 @@ export class HeatmapBlameAnnotationProvider extends BlameAnnotationProviderBase let start = process.hrtime(); - const decorationsMap = new Map(); + const decorationsMap = new Map< + string, + { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] } + >(); const computedHeatmap = await this.getComputedHeatmap(blame); let commit: GitBlameCommit | undefined; @@ -44,16 +46,16 @@ export class HeatmapBlameAnnotationProvider extends BlameAnnotationProviderBase if (decorationsMap.size) { start = process.hrtime(); - this.additionalDecorations = []; - for (const d of decorationsMap.values()) { - this.additionalDecorations.push(d); - this.editor.setDecorations(d.decoration, d.ranges); - } + this.setDecorations([...decorationsMap.values()]); Logger.log(cc, `${Strings.getDurationMilliseconds(start)} ms to apply recent changes annotations`); } - this.registerHoverProviders(Container.config.hovers.annotations); + // this.registerHoverProviders(Container.config.hovers.annotations); return true; } + + selection(_shaOrLine?: string | number): Promise { + return Promise.resolve(); + } } diff --git a/src/annotations/recentChangesAnnotationProvider.ts b/src/annotations/recentChangesAnnotationProvider.ts deleted file mode 100644 index 0d3e9cc..0000000 --- a/src/annotations/recentChangesAnnotationProvider.ts +++ /dev/null @@ -1,142 +0,0 @@ -'use strict'; -import { - MarkdownString, - Position, - Range, - Selection, - TextEditor, - TextEditorDecorationType, - TextEditorRevealType, -} from 'vscode'; -import { AnnotationProviderBase } from './annotationProvider'; -import { FileAnnotationType } from '../configuration'; -import { Container } from '../container'; -import { GitUri } from '../git/gitUri'; -import { Hovers } from '../hovers/hovers'; -import { Logger } from '../logger'; -import { log, Strings } from '../system'; -import { GitDocumentState, TrackedDocument } from '../trackers/gitDocumentTracker'; - -export class RecentChangesAnnotationProvider extends AnnotationProviderBase { - private readonly _uri: GitUri; - - constructor( - editor: TextEditor, - trackedDocument: TrackedDocument, - decoration: TextEditorDecorationType, - highlightDecoration: TextEditorDecorationType | undefined, - ) { - super(editor, trackedDocument, decoration, highlightDecoration); - - this._uri = trackedDocument.uri; - } - - @log() - async onProvideAnnotation(shaOrLine?: string | number): Promise { - const cc = Logger.getCorrelationContext(); - - this.annotationType = FileAnnotationType.RecentChanges; - - let ref1 = this._uri.sha; - let ref2; - if (typeof shaOrLine === 'string') { - if (shaOrLine !== this._uri.sha) { - ref2 = `${shaOrLine}^`; - } - } - - const commit = await Container.git.getCommitForFile(this._uri.repoPath, this._uri.fsPath, { - ref: ref2 ?? ref1, - }); - if (commit === undefined) return false; - - if (ref2 !== undefined) { - ref2 = commit.ref; - } else { - ref1 = commit.ref; - } - - const diff = await Container.git.getDiffForFile(this._uri, ref1, ref2); - if (diff === undefined) return false; - - let start = process.hrtime(); - - const cfg = Container.config; - const dateFormat = cfg.defaultDateFormat; - - this.decorations = []; - - let selection: Selection | undefined; - - for (const hunk of diff.hunks) { - // Subtract 2 because editor lines are 0-based and we will be adding 1 in the first iteration of the loop - let count = hunk.currentPosition.start - 2; - for (const hunkLine of hunk.lines) { - if (hunkLine.current === undefined) continue; - - count++; - - if (hunkLine.current.state === 'unchanged') continue; - - const range = this.editor.document.validateRange( - new Range(new Position(count, 0), new Position(count, Number.MAX_SAFE_INTEGER)), - ); - if (selection === undefined) { - selection = new Selection(range.start, range.end); - } - - let message: MarkdownString | undefined = undefined; - - if (cfg.hovers.enabled && cfg.hovers.annotations.enabled) { - if (cfg.hovers.annotations.details) { - this.decorations.push({ - hoverMessage: await Hovers.detailsMessage( - commit, - await GitUri.fromUri(this.editor.document.uri), - count, - dateFormat, - this.annotationType, - ), - range: range, - }); - } - - if (cfg.hovers.annotations.changes) { - message = await Hovers.changesMessage(commit, this._uri, count, hunkLine); - if (message === undefined) continue; - } - } - - this.decorations.push({ - hoverMessage: message, - range: range, - }); - } - } - - Logger.log(cc, `${Strings.getDurationMilliseconds(start)} ms to compute recent changes annotations`); - - if (this.decoration != null && this.decorations.length) { - start = process.hrtime(); - - this.editor.setDecorations(this.decoration, this.decorations); - - Logger.log(cc, `${Strings.getDurationMilliseconds(start)} ms to apply recent changes annotations`); - - if (selection !== undefined) { - this.editor.selection = selection; - this.editor.revealRange(selection, TextEditorRevealType.InCenterIfOutsideViewport); - } - } - - return true; - } - - selection(_shaOrLine?: string | number): Promise { - return Promise.resolve(undefined); - } - - validate(): Promise { - return Promise.resolve(true); - } -} diff --git a/src/commands/common.ts b/src/commands/common.ts index a304399..1affbd8 100644 --- a/src/commands/common.ts +++ b/src/commands/common.ts @@ -106,8 +106,8 @@ export enum Commands { SwitchMode = 'gitlens.switchMode', ToggleCodeLens = 'gitlens.toggleCodeLens', ToggleFileBlame = 'gitlens.toggleFileBlame', + ToggleFileChanges = 'gitlens.toggleFileChanges', ToggleFileHeatmap = 'gitlens.toggleFileHeatmap', - ToggleFileRecentChanges = 'gitlens.toggleFileRecentChanges', ToggleLineBlame = 'gitlens.toggleLineBlame', ToggleReviewMode = 'gitlens.toggleReviewMode', ToggleZenMode = 'gitlens.toggleZenMode', diff --git a/src/commands/toggleFileAnnotations.ts b/src/commands/toggleFileAnnotations.ts index 0459517..c9e2b3c 100644 --- a/src/commands/toggleFileAnnotations.ts +++ b/src/commands/toggleFileAnnotations.ts @@ -55,29 +55,29 @@ export class ToggleFileBlameCommand extends ActiveEditorCommand { } @command() -export class ToggleFileHeatmapCommand extends ActiveEditorCommand { +export class ToggleFileChangesCommand extends ActiveEditorCommand { constructor() { - super(Commands.ToggleFileHeatmap); + super(Commands.ToggleFileChanges); } execute(editor: TextEditor, uri?: Uri, args?: ToggleFileAnnotationCommandArgs): Promise { return toggleFileAnnotations(editor, uri, { ...args, - type: FileAnnotationType.Heatmap, + type: FileAnnotationType.Changes, }); } } @command() -export class ToggleFileRecentChangesCommand extends ActiveEditorCommand { +export class ToggleFileHeatmapCommand extends ActiveEditorCommand { constructor() { - super(Commands.ToggleFileRecentChanges); + super(Commands.ToggleFileHeatmap); } execute(editor: TextEditor, uri?: Uri, args?: ToggleFileAnnotationCommandArgs): Promise { return toggleFileAnnotations(editor, uri, { ...args, - type: FileAnnotationType.RecentChanges, + type: FileAnnotationType.Heatmap, }); } } diff --git a/src/config.ts b/src/config.ts index 9e8c591..0b01081 100644 --- a/src/config.ts +++ b/src/config.ts @@ -14,12 +14,17 @@ export interface Config { }; highlight: { enabled: boolean; - locations: HighlightLocations[]; + locations: BlameHighlightLocations[]; }; ignoreWhitespace: boolean; separateLines: boolean; toggleMode: AnnotationsToggleMode; }; + changes: { + locations: ChangesLocations[]; + toggleMode: AnnotationsToggleMode; + }; + codeLens: CodeLensConfig; currentLine: { dateFormat: string | null; enabled: boolean; @@ -29,7 +34,6 @@ export interface Config { }; scrollable: boolean; }; - codeLens: CodeLensConfig; debug: boolean; defaultDateFormat: string | null; defaultDateShortFormat: string | null; @@ -93,12 +97,6 @@ export interface Config { }; modes: Record; outputLevel: TraceLevel; - recentChanges: { - highlight: { - locations: HighlightLocations[]; - }; - toggleMode: AnnotationsToggleMode; - }; remotes: RemotesConfig[] | null; showWhatsNewAfterUpgrades: boolean; sortBranchesBy: BranchSorting; @@ -137,6 +135,12 @@ export interface AutolinkReference { ignoreCase?: boolean; } +export enum BlameHighlightLocations { + Gutter = 'gutter', + Line = 'line', + Overview = 'overview', +} + export enum BranchSorting { NameDesc = 'name:desc', NameAsc = 'name:asc', @@ -144,6 +148,11 @@ export enum BranchSorting { DateAsc = 'date:asc', } +export enum ChangesLocations { + Gutter = 'gutter', + Overview = 'overview', +} + export enum CodeLensCommand { DiffWithPrevious = 'gitlens.diffWithPrevious', RevealCommitInView = 'gitlens.revealCommitInView', @@ -181,8 +190,8 @@ export enum DateStyle { export enum FileAnnotationType { Blame = 'blame', + Changes = 'changes', Heatmap = 'heatmap', - RecentChanges = 'recentChanges', } export enum GravatarDefaultStyle { @@ -194,12 +203,6 @@ export enum GravatarDefaultStyle { Robot = 'robohash', } -export enum HighlightLocations { - Gutter = 'gutter', - Line = 'line', - Overview = 'overview', -} - export enum KeyMap { Alternate = 'alternate', Chorded = 'chorded', @@ -386,7 +389,7 @@ export interface ModeConfig { name: string; statusBarItemName?: string; description?: string; - annotations?: 'blame' | 'heatmap' | 'recentChanges'; + annotations?: 'blame' | 'changes' | 'heatmap'; codeLens?: boolean; currentLine?: boolean; hovers?: boolean; diff --git a/src/container.ts b/src/container.ts index 3366b4b..59e6a44 100644 --- a/src/container.ts +++ b/src/container.ts @@ -315,14 +315,14 @@ export class Container { config.blame.toggleMode = AnnotationsToggleMode.Window; command = Commands.ToggleFileBlame; break; + case 'changes': + config.changes.toggleMode = AnnotationsToggleMode.Window; + command = Commands.ToggleFileChanges; + break; case 'heatmap': config.heatmap.toggleMode = AnnotationsToggleMode.Window; command = Commands.ToggleFileHeatmap; break; - case 'recentChanges': - config.recentChanges.toggleMode = AnnotationsToggleMode.Window; - command = Commands.ToggleFileRecentChanges; - break; } if (command != null) { @@ -375,11 +375,11 @@ export class Container { `gitlens.${configuration.name('mode')}`, `gitlens.${configuration.name('modes')}`, `gitlens.${configuration.name('blame', 'toggleMode')}`, + `gitlens.${configuration.name('changes', 'toggleMode')}`, `gitlens.${configuration.name('codeLens')}`, `gitlens.${configuration.name('currentLine')}`, `gitlens.${configuration.name('heatmap', 'toggleMode')}`, `gitlens.${configuration.name('hovers')}`, - `gitlens.${configuration.name('recentChanges', 'toggleMode')}`, `gitlens.${configuration.name('statusBar')}`, `gitlens.${configuration.name('views', 'compare')}`, `gitlens.${configuration.name('views', 'fileHistory')}`, diff --git a/src/extension.ts b/src/extension.ts index e4a7c46..678e465 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,6 +11,7 @@ import { GitUri } from './git/gitUri'; import { Logger } from './logger'; import { Messages } from './messages'; import { Strings, Versions } from './system'; +import { ViewNode } from './views/nodes'; export async function activate(context: ExtensionContext) { const start = process.hrtime(); @@ -29,6 +30,10 @@ export async function activate(context: ExtensionContext) { return `GitCommit(${o.sha ? ` sha=${o.sha}` : ''}${o.repoPath ? ` repoPath=${o.repoPath}` : ''})`; } + if (ViewNode.is(o)) { + return o.toString(); + } + return undefined; }); diff --git a/src/git/formatters/commitFormatter.ts b/src/git/formatters/commitFormatter.ts index d2e0f78..c19f6b7 100644 --- a/src/git/formatters/commitFormatter.ts +++ b/src/git/formatters/commitFormatter.ts @@ -32,7 +32,6 @@ const emptyStr = ''; const hasTokenRegexMap = new Map(); export interface CommitFormatOptions extends FormatOptions { - annotationType?: FileAnnotationType; autolinkedIssuesOrPullRequests?: Map; dateStyle?: DateStyle; getBranchAndTagTips?: (sha: string) => string | undefined; @@ -270,11 +269,6 @@ export class CommitFormatter extends Formatter { )} "Open Changes")${separator}`; if (this._item.previousSha != null) { - let annotationType = this._options.annotationType; - if (annotationType === FileAnnotationType.RecentChanges) { - annotationType = FileAnnotationType.Blame; - } - const uri = GitUri.toRevisionUri( this._item.previousSha, this._item.previousUri.fsPath, @@ -282,7 +276,7 @@ export class CommitFormatter extends Formatter { ); commands += `[$(history)](${OpenFileAtRevisionCommand.getMarkdownCommandArgs( uri, - annotationType ?? FileAnnotationType.Blame, + FileAnnotationType.Blame, this._options.line, )} "Blame Previous Revision")${separator}`; } diff --git a/src/git/git.ts b/src/git/git.ts index 27f2355..6ab4b5c 100644 --- a/src/git/git.ts +++ b/src/git/git.ts @@ -614,6 +614,67 @@ export namespace Git { } } + export async function diff__contents( + repoPath: string, + fileName: string, + ref: string, + contents: string, + options: { encoding?: string; filters?: GitDiffFilter[]; similarityThreshold?: number | null } = {}, + ): Promise { + const params = [ + 'diff', + `-M${options.similarityThreshold == null ? '' : `${options.similarityThreshold}%`}`, + '--no-ext-diff', + '-U0', + '--minimal', + ]; + + if (options.filters != null && options.filters.length !== 0) { + params.push(`--diff-filter=${options.filters.join(emptyStr)}`); + } + + // // ^3 signals an untracked file in a stash and if we are trying to find its parent, use the root sha + // if (ref.endsWith('^3^')) { + // ref = rootSha; + // } + // params.push(GitRevision.isUncommittedStaged(ref) ? '--staged' : ref); + + params.push('--no-index'); + + try { + return await git( + { + cwd: repoPath, + configs: ['-c', 'color.diff=false'], + encoding: options.encoding === 'utf8' ? 'utf8' : 'binary', + stdin: contents, + }, + ...params, + '--', + fileName, + // Pipe the contents to stdin + '-', + ); + } catch (ex) { + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions + if (ex.stdout) { + return ex.stdout; + } + + const match = GitErrors.badRevision.exec(ex.message); + if (match !== null) { + const [, matchedRef] = match; + + // If the bad ref is trying to find a parent ref, assume we hit to the last commit, so try again using the root sha + if (matchedRef === ref && matchedRef != null && matchedRef.endsWith('^')) { + return Git.diff__contents(repoPath, fileName, rootSha, contents, options); + } + } + + throw ex; + } + } + export function diff__name_status( repoPath: string, ref1?: string, @@ -766,7 +827,7 @@ export namespace Git { renames = true, reverse = false, skip, - simple = false, + format = 'default', startLine, endLine, }: { @@ -777,14 +838,17 @@ export namespace Git { renames?: boolean; reverse?: boolean; skip?: number; - simple?: boolean; + format?: 'refs' | 'simple' | 'default'; startLine?: number; endLine?: number; } = {}, ) { const [file, root] = Git.splitPath(fileName, repoPath); - const params = ['log', `--format=${simple ? GitLogParser.simpleFormat : GitLogParser.defaultFormat}`]; + const params = [ + 'log', + `--format=${format === 'default' ? GitLogParser.defaultFormat : GitLogParser.simpleFormat}`, + ]; if (limit && !reverse) { params.push(`-n${limit}`); @@ -808,15 +872,17 @@ export namespace Git { params.push('--first-parent'); } - if (startLine == null) { - if (simple) { - params.push('--name-status'); + if (format !== 'refs') { + if (startLine == null) { + if (format === 'simple') { + params.push('--name-status'); + } else { + params.push('--numstat', '--summary'); + } } else { - params.push('--numstat', '--summary'); + // Don't include --name-status or -s because Git won't honor it + params.push(`-L ${startLine},${endLine == null ? startLine : endLine}:${file}`); } - } else { - // Don't include --name-status or -s because Git won't honor it - params.push(`-L ${startLine},${endLine == null ? startLine : endLine}:${file}`); } if (ref && !GitRevision.isUncommittedStaged(ref)) { diff --git a/src/git/gitService.ts b/src/git/gitService.ts index b68f716..d3c3797 100644 --- a/src/git/gitService.ts +++ b/src/git/gitService.ts @@ -1192,6 +1192,17 @@ export class GitService implements Disposable { } @log() + async getOldestUnpushedRefForFile(repoPath: string, fileName: string): Promise { + const data = await Git.log__file(repoPath, fileName, '@{push}..', { + format: 'refs', + renames: true, + }); + if (data == null || data.length === 0) return undefined; + + return GitLogParser.parseLastRefOnly(data); + } + + @log() getConfig(key: string, repoPath?: string): Promise { return Git.config__get(key, repoPath); } @@ -1334,19 +1345,116 @@ export class GitService implements Disposable { const [file, root] = Git.splitPath(fileName, repoPath, false); try { - let data; - if (ref1 != null && ref2 == null && !GitRevision.isUncommittedStaged(ref1)) { - data = await Git.show__diff(root, file, ref1, originalFileName, { - similarityThreshold: Container.config.advanced.similarityThreshold, - }); - } else { - data = await Git.diff(root, file, ref1, ref2, { - ...options, - filters: ['M'], - similarityThreshold: Container.config.advanced.similarityThreshold, - }); + // let data; + // if (ref2 == null && ref1 != null && !GitRevision.isUncommittedStaged(ref1)) { + // data = await Git.show__diff(root, file, ref1, originalFileName, { + // similarityThreshold: Container.config.advanced.similarityThreshold, + // }); + // } else { + const data = await Git.diff(root, file, ref1, ref2, { + ...options, + filters: ['M'], + similarityThreshold: Container.config.advanced.similarityThreshold, + }); + // } + + const diff = GitDiffParser.parse(data); + return diff; + } catch (ex) { + // Trap and cache expected diff errors + if (document.state != null) { + const msg = ex?.toString() ?? ''; + Logger.debug(cc, `Cache replace (with empty promise): '${key}'`); + + const value: CachedDiff = { + item: emptyPromise as Promise, + errorMessage: msg, + }; + document.state.set(key, value); + + return emptyPromise as Promise; + } + + return undefined; + } + } + + @log({ + args: { + 1: _contents => '', + }, + }) + async getDiffForFileContents( + uri: GitUri, + ref: string, + contents: string, + originalFileName?: string, + ): Promise { + const cc = Logger.getCorrelationContext(); + + const key = `diff:${Strings.sha1(contents)}`; + + const doc = await Container.tracker.getOrAdd(uri); + if (this.useCaching) { + if (doc.state != null) { + const cachedDiff = doc.state.get(key); + if (cachedDiff != null) { + Logger.debug(cc, `Cache hit: ${key}`); + return cachedDiff.item; + } } + Logger.debug(cc, `Cache miss: ${key}`); + + if (doc.state == null) { + doc.state = new GitDocumentState(doc.key); + } + } + + const promise = this.getDiffForFileContentsCore( + uri.repoPath, + uri.fsPath, + ref, + contents, + originalFileName, + { encoding: GitService.getEncoding(uri) }, + doc, + key, + cc, + ); + + if (doc.state != null) { + Logger.debug(cc, `Cache add: '${key}'`); + + const value: CachedDiff = { + item: promise as Promise, + }; + doc.state.set(key, value); + } + + return promise; + } + + async getDiffForFileContentsCore( + repoPath: string | undefined, + fileName: string, + ref: string, + contents: string, + originalFileName: string | undefined, + options: { encoding?: string }, + document: TrackedDocument, + key: string, + cc: LogCorrelationContext | undefined, + ): Promise { + const [file, root] = Git.splitPath(fileName, repoPath, false); + + try { + const data = await Git.diff__contents(root, file, ref, contents, { + ...options, + filters: ['M'], + similarityThreshold: Container.config.advanced.similarityThreshold, + }); + const diff = GitDiffParser.parse(data); return diff; } catch (ex) { @@ -1381,10 +1489,10 @@ export class GitService implements Disposable { if (diff == null) return undefined; const line = editorLine + 1; - const hunk = diff.hunks.find(c => c.currentPosition.start <= line && c.currentPosition.end >= line); + const hunk = diff.hunks.find(c => c.current.position.start <= line && c.current.position.end >= line); if (hunk == null) return undefined; - return hunk.lines[line - hunk.currentPosition.start]; + return hunk.lines[line - hunk.current.position.start]; } catch (ex) { return undefined; } @@ -2071,7 +2179,7 @@ export class GitService implements Disposable { limit: skip + 1, // startLine: editorLine != null ? editorLine + 1 : undefined, reverse: true, - simple: true, + format: 'simple', }); if (data == null || data.length === 0) return undefined; @@ -2082,7 +2190,7 @@ export class GitService implements Disposable { filters: ['R', 'C'], limit: 1, // startLine: editorLine != null ? editorLine + 1 : undefined - simple: true, + format: 'simple', }); if (data == null || data.length === 0) { return GitUri.fromFile(file ?? fileName, repoPath, nextRef); @@ -2319,7 +2427,7 @@ export class GitService implements Disposable { data = await Git.log__file(repoPath, fileName, ref, { limit: skip + 2, firstParent: firstParent, - simple: true, + format: 'simple', startLine: editorLine != null ? editorLine + 1 : undefined, }); } catch (ex) { @@ -2882,7 +2990,7 @@ export class GitService implements Disposable { data = await Git.log__file(repoPath, '.', ref, { filters: ['R', 'C', 'D'], limit: 1, - simple: true, + format: 'simple', }); if (data == null || data.length === 0) break; diff --git a/src/git/models/diff.ts b/src/git/models/diff.ts index 1930eff..e8cc438 100644 --- a/src/git/models/diff.ts +++ b/src/git/models/diff.ts @@ -1,6 +1,5 @@ 'use strict'; import { GitDiffParser } from '../parsers/diffParser'; -import { memoize } from '../../system'; export interface GitDiffLine { line: string; @@ -16,13 +15,30 @@ export interface GitDiffHunkLine { export class GitDiffHunk { constructor( public readonly diff: string, - public currentPosition: { start: number; end: number }, - public previousPosition: { start: number; end: number }, + public current: { + count: number; + position: { start: number; end: number }; + }, + public previous: { + count: number; + position: { start: number; end: number }; + }, ) {} - @memoize() get lines(): GitDiffHunkLine[] { - return GitDiffParser.parseHunk(this); + return this.parseHunk().lines; + } + + get state(): 'added' | 'changed' | 'removed' { + return this.parseHunk().state; + } + + private parsedHunk: { lines: GitDiffHunkLine[]; state: 'added' | 'changed' | 'removed' } | undefined; + private parseHunk() { + if (this.parsedHunk == null) { + this.parsedHunk = GitDiffParser.parseHunk(this); + } + return this.parsedHunk; } } diff --git a/src/git/parsers/diffParser.ts b/src/git/parsers/diffParser.ts index 7b4bfab..400c143 100644 --- a/src/git/parsers/diffParser.ts +++ b/src/git/parsers/diffParser.ts @@ -27,7 +27,9 @@ export class GitDiffParser { [, previousStart, previousCount, currentStart, currentCount, hunk] = match; + previousCount = Number(previousCount) || 0; previousStart = Number(previousStart) || 0; + currentCount = Number(currentCount) || 0; currentStart = Number(currentStart) || 0; hunks.push( @@ -35,12 +37,18 @@ export class GitDiffParser { // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 ` ${hunk}`.substr(1), { - start: currentStart, - end: currentStart + (Number(currentCount) || 0), + count: currentCount, + position: { + start: currentStart, + end: currentStart + (currentCount > 0 ? currentCount - 1 : 0), + }, }, { - start: previousStart, - end: previousStart + (Number(previousCount) || 0), + count: previousCount, + position: { + start: previousStart, + end: previousStart + (previousCount > 0 ? previousCount - 1 : 0), + }, }, ), ); @@ -56,14 +64,18 @@ export class GitDiffParser { } @debug({ args: false, singleLine: true }) - static parseHunk(hunk: GitDiffHunk): GitDiffHunkLine[] { + static parseHunk(hunk: GitDiffHunk): { lines: GitDiffHunkLine[]; state: 'added' | 'changed' | 'removed' } { const currentLines: (GitDiffLine | undefined)[] = []; const previousLines: (GitDiffLine | undefined)[] = []; + let hasAddedOrChanged; + let hasRemoved; + let removed = 0; for (const l of Strings.lines(hunk.diff)) { switch (l[0]) { case '+': + hasAddedOrChanged = true; currentLines.push({ line: ` ${l.substring(1)}`, state: 'added', @@ -78,6 +90,7 @@ export class GitDiffParser { break; case '-': + hasRemoved = true; removed++; previousLines.push({ @@ -115,7 +128,10 @@ export class GitDiffParser { }); } - return hunkLines; + return { + lines: hunkLines, + state: hasAddedOrChanged && hasRemoved ? 'changed' : hasAddedOrChanged ? 'added' : 'removed', + }; } @debug({ args: false, singleLine: true }) diff --git a/src/git/parsers/logParser.ts b/src/git/parsers/logParser.ts index 43c38d2..7036525 100644 --- a/src/git/parsers/logParser.ts +++ b/src/git/parsers/logParser.ts @@ -25,6 +25,7 @@ const fileStatusAndSummaryRegex = /^(\d+?|-)\s+?(\d+?|-)\s+?(.*)(?:\n\s(delete|r const fileStatusAndSummaryRenamedFileRegex = /(.+)\s=>\s(.+)/; const fileStatusAndSummaryRenamedFilePathRegex = /(.*?){(.+?)\s=>\s(.*?)}(.*)/; +const logFileRefsRegex = /^ (.*)/gm; const logFileSimpleRegex = /^ (.*)\s*(?:(?:diff --git a\/(.*) b\/(.*))|(?:(\S)\S*\t([^\t\n]+)(?:\t(.+))?))/gm; const logFileSimpleRenamedRegex = /^ (\S+)\s*(.*)$/s; const logFileSimpleRenamedFilesRegex = /^(\S)\S*\t([^\t\n]+)(?:\t(.+)?)?$/gm; @@ -75,6 +76,7 @@ export class GitLogParser { `${lb}f${rb}`, ].join('%n'); + static simpleRefs = `${lb}r${rb}${sp}%H`; static simpleFormat = `${lb}r${rb}${sp}%H`; @debug({ args: false }) @@ -468,6 +470,47 @@ export class GitLogParser { } @debug({ args: false }) + static parseLastRefOnly(data: string): string | undefined { + let ref; + let match; + do { + match = logFileRefsRegex.exec(data); + if (match == null) break; + + [, ref] = match; + } while (true); + + // Ensure the regex state is reset + logFileRefsRegex.lastIndex = 0; + + return ref; + } + + @debug({ args: false }) + static parseRefsOnly(data: string): string[] { + const refs = []; + + let ref; + let match; + do { + match = logFileRefsRegex.exec(data); + if (match == null) break; + + [, ref] = match; + + if (ref == null || ref.length === 0) { + // Stops excessive memory usage -- https://bugs.chromium.org/p/v8/issues/detail?id=2869 + refs.push(` ${ref}`.substr(1)); + } + } while (true); + + // Ensure the regex state is reset + logFileRefsRegex.lastIndex = 0; + + return refs; + } + + @debug({ args: false }) static parseSimple( data: string, skip: number, diff --git a/src/hovers/hovers.ts b/src/hovers/hovers.ts index 0bc1d73..3b1979f 100644 --- a/src/hovers/hovers.ts +++ b/src/hovers/hovers.ts @@ -1,13 +1,13 @@ 'use strict'; import { MarkdownString } from 'vscode'; import { DiffWithCommand, ShowQuickCommitCommand } from '../commands'; -import { FileAnnotationType } from '../configuration'; import { GlyphChars } from '../constants'; import { Container } from '../container'; import { CommitFormatter, GitBlameCommit, GitCommit, + GitDiffHunk, GitDiffHunkLine, GitLogCommit, GitRemote, @@ -19,23 +19,13 @@ import { Iterables, Promises, Strings } from '../system'; export namespace Hovers { export async function changesMessage( - commit: GitBlameCommit, - uri: GitUri, - editorLine: number, - ): Promise; - export async function changesMessage( - commit: GitLogCommit, - uri: GitUri, - editorLine: number, - hunkLine: GitDiffHunkLine, - ): Promise; - export async function changesMessage( commit: GitBlameCommit | GitLogCommit, uri: GitUri, editorLine: number, - hunkLine?: GitDiffHunkLine, ): Promise { const documentRef = uri.sha; + + let hunkLine; if (GitBlameCommit.is(commit)) { // TODO: Figure out how to optimize this let ref; @@ -51,17 +41,18 @@ export namespace Hovers { const commitLine = commit.lines.find(l => l.line === line) ?? commit.lines[0]; let originalFileName = commit.originalFileName; - if (originalFileName === undefined) { + if (originalFileName == null) { if (uri.fsPath !== commit.uri.fsPath) { originalFileName = commit.fileName; } } editorLine = commitLine.originalLine - 1; + // TODO: Doesn't work with dirty files -- pass in editor? or contents? hunkLine = await Container.git.getDiffForLine(uri, editorLine, ref, undefined, originalFileName); // If we didn't find a diff & ref is undefined (meaning uncommitted), check for a staged diff - if (hunkLine === undefined && ref === undefined) { + if (hunkLine == null && ref == null) { hunkLine = await Container.git.getDiffForLine( uri, editorLine, @@ -72,7 +63,7 @@ export namespace Hovers { } } - if (hunkLine === undefined || commit.previousSha === undefined) return undefined; + if (hunkLine == null || commit.previousSha == null) return undefined; const diff = getDiffFromHunkLine(hunkLine); @@ -81,11 +72,11 @@ export namespace Hovers { let current; if (commit.isUncommitted) { const diffUris = await commit.getPreviousLineDiffUris(uri, editorLine, documentRef); - if (diffUris === undefined || diffUris.previous === undefined) { + if (diffUris == null || diffUris.previous == null) { return undefined; } - message = `[$(compare-changes) Changes](${DiffWithCommand.getMarkdownCommandArgs({ + message = `[$(compare-changes)](${DiffWithCommand.getMarkdownCommandArgs({ lhs: { sha: diffUris.previous.sha ?? '', uri: diffUris.previous.documentUri(), @@ -99,7 +90,7 @@ export namespace Hovers { })} "Open Changes")`; previous = - diffUris.previous.sha === undefined || diffUris.previous.isUncommitted + diffUris.previous.sha == null || diffUris.previous.isUncommitted ? `_${GitRevision.shorten(diffUris.previous.sha, { strings: { working: 'Working Tree', @@ -107,12 +98,10 @@ export namespace Hovers { })}_` : `[$(git-commit) ${GitRevision.shorten( diffUris.previous.sha || '', - )}](${ShowQuickCommitCommand.getMarkdownCommandArgs( - diffUris.previous.sha || '', - )} "Show Commit Details")`; + )}](${ShowQuickCommitCommand.getMarkdownCommandArgs(diffUris.previous.sha || '')} "Show Commit")`; current = - diffUris.current.sha === undefined || diffUris.current.isUncommitted + diffUris.current.sha == null || diffUris.current.isUncommitted ? `_${GitRevision.shorten(diffUris.current.sha, { strings: { working: 'Working Tree', @@ -120,25 +109,68 @@ export namespace Hovers { })}_` : `[$(git-commit) ${GitRevision.shorten( diffUris.current.sha || '', - )}](${ShowQuickCommitCommand.getMarkdownCommandArgs( - diffUris.current.sha || '', - )} "Show Commit Details")`; + )}](${ShowQuickCommitCommand.getMarkdownCommandArgs(diffUris.current.sha || '')} "Show Commit")`; } else { - message = `[$(compare-changes) Changes](${DiffWithCommand.getMarkdownCommandArgs( + message = `[$(compare-changes)](${DiffWithCommand.getMarkdownCommandArgs( commit, editorLine, )} "Open Changes")`; previous = `[$(git-commit) ${commit.previousShortSha}](${ShowQuickCommitCommand.getMarkdownCommandArgs( commit.previousSha, - )} "Show Commit Details")`; + )} "Show Commit")`; current = `[$(git-commit) ${commit.shortSha}](${ShowQuickCommitCommand.getMarkdownCommandArgs( commit.sha, - )} "Show Commit Details")`; + )} "Show Commit")`; } - message += `   ${GlyphChars.Dash}   ${previous}  ${GlyphChars.ArrowLeftRightLong}  ${current}\n${diff}`; + message = `${diff}\n---\n\nChanges  ${previous}  ${GlyphChars.ArrowLeftRightLong}  ${current}   |   ${message}`; + + const markdown = new MarkdownString(message, true); + markdown.isTrusted = true; + return markdown; + } + + export function localChangesMessage( + fromCommit: GitLogCommit | undefined, + uri: GitUri, + editorLine: number, + hunk: GitDiffHunk, + ): MarkdownString { + const diff = getDiffFromHunk(hunk); + + let message; + let previous; + let current; + if (fromCommit == null) { + previous = '_Working Tree_'; + current = '_Unsaved_'; + } else { + const file = fromCommit.findFile(uri.fsPath)!; + + message = `[$(compare-changes)](${DiffWithCommand.getMarkdownCommandArgs({ + lhs: { + sha: fromCommit.sha, + uri: GitUri.fromFile(file, uri.repoPath!, undefined, true).toFileUri(), + }, + rhs: { + sha: '', + uri: uri.toFileUri(), + }, + repoPath: uri.repoPath!, + line: editorLine, + })} "Open Changes")`; + + previous = `[$(git-commit) ${fromCommit.shortSha}](${ShowQuickCommitCommand.getMarkdownCommandArgs( + fromCommit.sha, + )} "Show Commit")`; + + current = '_Working Tree_'; + } + message = `${diff}\n---\n\nLocal Changes  ${previous}  ${ + GlyphChars.ArrowLeftRightLong + }  ${current}${message == null ? '' : `   |   ${message}`}`; const markdown = new MarkdownString(message, true); markdown.isTrusted = true; @@ -150,7 +182,6 @@ export namespace Hovers { uri: GitUri, editorLine: number, dateFormat: string | null, - annotationType: FileAnnotationType | undefined, ): Promise { if (dateFormat === null) { dateFormat = 'MMMM Do, YYYY h:mma'; @@ -166,7 +197,6 @@ export namespace Hovers { ]); const details = CommitFormatter.fromTemplate(Container.config.hovers.detailsMarkdownFormat, commit, { - annotationType: annotationType, autolinkedIssuesOrPullRequests: autolinkedIssuesOrPullRequests, dateFormat: dateFormat, line: editorLine, @@ -182,13 +212,17 @@ export namespace Hovers { return markdown; } - function getDiffFromHunkLine(hunkLine: GitDiffHunkLine): string { - if (Container.config.hovers.changesDiff === 'hunk') { - return `\`\`\`diff\n${hunkLine.hunk.diff}\n\`\`\``; + function getDiffFromHunk(hunk: GitDiffHunk): string { + return `\`\`\`diff\n${hunk.diff.trim()}\n\`\`\``; + } + + function getDiffFromHunkLine(hunkLine: GitDiffHunkLine, diffStyle?: 'line' | 'hunk'): string { + if (diffStyle === 'hunk' || (diffStyle == null && Container.config.hovers.changesDiff === 'hunk')) { + return getDiffFromHunk(hunkLine.hunk); } - return `\`\`\`diff${hunkLine.previous === undefined ? '' : `\n-${hunkLine.previous.line}`}${ - hunkLine.current === undefined ? '' : `\n+${hunkLine.current.line}` + return `\`\`\`diff${hunkLine.previous == null ? '' : `\n-${hunkLine.previous.line.trim()}`}${ + hunkLine.current == null ? '' : `\n+${hunkLine.current.line.trim()}` }\n\`\`\``; } @@ -209,7 +243,7 @@ export namespace Hovers { } const remote = remotes.find(r => r.default && r.provider != null); - if (remote === undefined) { + if (remote == null) { Logger.debug(cc, `completed ${GlyphChars.Dot} ${Strings.getDurationMilliseconds(start)} ms`); return undefined; @@ -223,24 +257,34 @@ export namespace Hovers { timeout: timeout, }); - if (autolinks !== undefined && (Logger.level === TraceLevel.Debug || Logger.isDebugging)) { - const timeouts = [ - ...Iterables.filterMap(autolinks.values(), issue => - issue instanceof Promises.CancellationError ? issue.promise : undefined, - ), - ]; - - // If there are any PRs that timed out, refresh the annotation(s) once they complete - if (timeouts.length !== 0) { + if (autolinks != null && (Logger.level === TraceLevel.Debug || Logger.isDebugging)) { + // If there are any issues/PRs that timed out, log it + const count = Iterables.count(autolinks.values(), pr => pr instanceof Promises.CancellationError); + if (count !== 0) { Logger.debug( cc, - `timed out ${GlyphChars.Dash} issue/pr queries (${ - timeouts.length - }) took too long (over ${timeout} ms) ${GlyphChars.Dot} ${Strings.getDurationMilliseconds( - start, - )} ms`, + `timed out ${ + GlyphChars.Dash + } ${count} issue/pull request queries took too long (over ${timeout} ms) ${ + GlyphChars.Dot + } ${Strings.getDurationMilliseconds(start)} ms`, ); + // const pending = [ + // ...Iterables.map(autolinks.values(), issueOrPullRequest => + // issueOrPullRequest instanceof Promises.CancellationError + // ? issueOrPullRequest.promise + // : undefined, + // ), + // ]; + // void Promise.all(pending).then(() => { + // Logger.debug( + // cc, + // `${GlyphChars.Dot} ${count} issue/pull request queries completed; refreshing...`, + // ); + // void commands.executeCommand('editor.action.showHover'); + // }); + return autolinks; } } diff --git a/src/hovers/lineHoverController.ts b/src/hovers/lineHoverController.ts index 3c5505d..b298e64 100644 --- a/src/hovers/lineHoverController.ts +++ b/src/hovers/lineHoverController.ts @@ -12,7 +12,7 @@ import { Uri, window, } from 'vscode'; -import { configuration } from '../configuration'; +import { configuration, FileAnnotationType } from '../configuration'; import { Container } from '../container'; import { Hovers } from './hovers'; import { LinesChangeEvent } from '../trackers/gitLineTracker'; @@ -98,8 +98,10 @@ export class LineHoverController implements Disposable { if (commit === undefined) return undefined; // Avoid double annotations if we are showing the whole-file hover blame annotations - const fileAnnotations = await Container.fileAnnotations.getAnnotationType(window.activeTextEditor); - if (fileAnnotations !== undefined && Container.config.hovers.annotations.details) return undefined; + if (Container.config.hovers.annotations.details) { + const fileAnnotations = await Container.fileAnnotations.getAnnotationType(window.activeTextEditor); + if (fileAnnotations === FileAnnotationType.Blame) return undefined; + } const wholeLine = Container.config.hovers.currentLine.over === 'line'; // If we aren't showing the hover over the whole line, make sure the annotation is on @@ -140,7 +142,6 @@ export class LineHoverController implements Disposable { trackedDocument.uri, editorLine, Container.config.defaultDateFormat, - fileAnnotations, ); return new Hover(message, range); } @@ -166,7 +167,7 @@ export class LineHoverController implements Disposable { // Avoid double annotations if we are showing the whole-file hover blame annotations if (Container.config.hovers.annotations.changes) { const fileAnnotations = await Container.fileAnnotations.getAnnotationType(window.activeTextEditor); - if (fileAnnotations !== undefined) return undefined; + if (fileAnnotations === FileAnnotationType.Blame) return undefined; } const wholeLine = Container.config.hovers.currentLine.over === 'line'; diff --git a/src/views/nodes/viewNode.ts b/src/views/nodes/viewNode.ts index 9d7c4b1..5a79e09 100644 --- a/src/views/nodes/viewNode.ts +++ b/src/views/nodes/viewNode.ts @@ -58,6 +58,10 @@ export interface ViewNode { @logName((c, name) => `${name}${c.id != null ? `(${c.id})` : ''}`) export abstract class ViewNode { + static is(node: any): node is ViewNode { + return node instanceof ViewNode; + } + constructor(uri: GitUri, public readonly view: TView, protected readonly parent?: ViewNode) { this._uri = uri; } diff --git a/src/views/viewCommands.ts b/src/views/viewCommands.ts index 00e7514..a75b97f 100644 --- a/src/views/viewCommands.ts +++ b/src/views/viewCommands.ts @@ -300,7 +300,7 @@ export class ViewCommands { void (await this.openFile(node)); void (await Container.fileAnnotations.toggle( window.activeTextEditor, - FileAnnotationType.RecentChanges, + FileAnnotationType.Changes, node.ref, true, )); @@ -319,7 +319,7 @@ export class ViewCommands { void (await this.openRevision(node, { showOptions: { preserveFocus: true, preview: true } })); void (await Container.fileAnnotations.toggle( window.activeTextEditor, - FileAnnotationType.RecentChanges, + FileAnnotationType.Changes, node.ref, true, )); diff --git a/src/webviews/apps/settings/partials/blame.ejs b/src/webviews/apps/settings/partials/blame.ejs index 950bff8..bc40050 100644 --- a/src/webviews/apps/settings/partials/blame.ejs +++ b/src/webviews/apps/settings/partials/blame.ejs @@ -136,7 +136,7 @@ data-setting-type="array" disabled /> - + @@ -166,7 +166,7 @@ data-setting-type="array" disabled /> - + diff --git a/src/webviews/apps/settings/partials/recent-changes.ejs b/src/webviews/apps/settings/partials/changes.ejs similarity index 51% rename from src/webviews/apps/settings/partials/recent-changes.ejs rename to src/webviews/apps/settings/partials/changes.ejs index 60c65f3..006cde5 100644 --- a/src/webviews/apps/settings/partials/recent-changes.ejs +++ b/src/webviews/apps/settings/partials/changes.ejs @@ -1,7 +1,7 @@ -
+

- Recent Changes + Gutter Changes

- Adds on-demand recent changes annotations to highlight lines changed by the most recent commit + Adds on-demand gutter changes annotations to highlight any local changes or lines changed by the most recent + commit

Use the - command to turn the annotations on or off

@@ -42,8 +43,8 @@
- - @@ -54,42 +55,28 @@
- +
- -
-
- -
-
- - +
@@ -100,17 +87,12 @@ -
diff --git a/src/webviews/apps/settings/settings.ejs b/src/webviews/apps/settings/settings.ejs index ce10669..5218b26 100644 --- a/src/webviews/apps/settings/settings.ejs +++ b/src/webviews/apps/settings/settings.ejs @@ -188,9 +188,9 @@ <%- include partials/blame.ejs -%> - <%- include partials/heatmap.ejs -%> + <%- include partials/changes.ejs -%> - <%- include partials/recent-changes.ejs -%> + <%- include partials/heatmap.ejs -%> <%- include partials/dates.ejs -%> @@ -310,18 +310,18 @@ Gutter HeatmapGutter Changes
  • Recent ChangesGutter Heatmap