Skip to content

Commit 84154cc

Browse files
committed
Adds support for going to next/previous change in Changes annotation (wip)
Fixes missing disposal of heatmap decorations Cleans up file annotations code
1 parent ea0a82c commit 84154cc

8 files changed

+266
-157
lines changed

package.json

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5555,6 +5555,16 @@
55555555
"light": "images/light/icon-git.svg"
55565556
}
55575557
},
5558+
{
5559+
"command": "gitlens.annotations.nextChange",
5560+
"title": "Next Change",
5561+
"icon": "$(arrow-down)"
5562+
},
5563+
{
5564+
"command": "gitlens.annotations.previousChange",
5565+
"title": "Previous Change",
5566+
"icon": "$(arrow-up)"
5567+
},
55585568
{
55595569
"command": "gitlens.clearFileAnnotations",
55605570
"title": "Clear File Annotations",
@@ -8993,9 +9003,17 @@
89939003
"command": "gitlens.toggleFileBlameInDiffRight",
89949004
"when": "false"
89959005
},
9006+
{
9007+
"command": "gitlens.annotations.nextChange",
9008+
"when": "false"
9009+
},
9010+
{
9011+
"command": "gitlens.annotations.previousChange",
9012+
"when": "false"
9013+
},
89969014
{
89979015
"command": "gitlens.clearFileAnnotations",
8998-
"when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computed"
9016+
"when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus =~ /computed\\b/"
89999017
},
90009018
{
90019019
"command": "gitlens.computingFileAnnotations",
@@ -11105,12 +11123,12 @@
1110511123
},
1110611124
{
1110711125
"command": "gitlens.computingFileAnnotations",
11108-
"when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computing && config.gitlens.menus.editorGroup.blame",
11126+
"when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus =~ /computing\\b/ && config.gitlens.menus.editorGroup.blame",
1110911127
"group": "navigation@100"
1111011128
},
1111111129
{
1111211130
"command": "gitlens.clearFileAnnotations",
11113-
"when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus == computed && config.gitlens.menus.editorGroup.blame",
11131+
"when": "gitlens:activeFileStatus =~ /blameable/ && gitlens:annotationStatus =~ /computed\\b/ && config.gitlens.menus.editorGroup.blame",
1111411132
"group": "navigation@100"
1111511133
},
1111611134
{
Lines changed: 109 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,12 @@
1-
import type {
2-
DecorationOptions,
3-
Range,
4-
TextDocument,
5-
TextEditor,
6-
TextEditorDecorationType,
7-
TextEditorSelectionChangeEvent,
8-
Uri,
9-
} from 'vscode';
1+
import type { TextEditor, TextEditorDecorationType, TextEditorSelectionChangeEvent } from 'vscode';
102
import { Disposable, window } from 'vscode';
113
import type { FileAnnotationType } from '../config';
4+
import type { Container } from '../container';
125
import { setContext } from '../system/context';
136
import { Logger } from '../system/logger';
147
import type { GitDocumentState } from '../trackers/gitDocumentTracker';
158
import type { TrackedDocument } from '../trackers/trackedDocument';
9+
import type { Decoration } from './annotations';
1610

1711
export type AnnotationStatus = 'computing' | 'computed';
1812

@@ -28,23 +22,16 @@ export function getEditorCorrelationKey(editor: TextEditor | undefined): TextEdi
2822
export abstract class AnnotationProviderBase<TContext extends AnnotationContext = AnnotationContext>
2923
implements Disposable
3024
{
31-
annotationContext: TContext | undefined;
32-
correlationKey: TextEditorCorrelationKey;
33-
document: TextDocument;
34-
status: AnnotationStatus | undefined;
35-
36-
private decorations:
37-
| { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] | DecorationOptions[] }[]
38-
| undefined;
25+
private decorations: Decoration[] | undefined;
3926
protected disposable: Disposable;
4027

4128
constructor(
29+
protected readonly container: Container,
4230
public readonly annotationType: FileAnnotationType,
43-
public editor: TextEditor,
31+
editor: TextEditor,
4432
protected readonly trackedDocument: TrackedDocument<GitDocumentState>,
4533
) {
46-
this.correlationKey = getEditorCorrelationKey(this.editor);
47-
this.document = this.editor.document;
34+
this.editor = editor;
4835

4936
this.disposable = Disposable.from(
5037
window.onDidChangeTextEditorSelection(this.onTextEditorSelectionChanged, this),
@@ -57,36 +44,98 @@ export abstract class AnnotationProviderBase<TContext extends AnnotationContext
5744
this.disposable.dispose();
5845
}
5946

47+
private _annotationContext: TContext | undefined;
48+
get annotationContext(): TContext | undefined {
49+
return this._annotationContext;
50+
}
51+
protected set annotationContext(value: TContext | undefined) {
52+
this._annotationContext = value;
53+
}
54+
55+
private _correlationKey!: TextEditorCorrelationKey;
56+
get correlationKey(): TextEditorCorrelationKey {
57+
return this._correlationKey;
58+
}
59+
60+
private _editor!: TextEditor;
61+
get editor(): TextEditor {
62+
return this._editor;
63+
}
64+
protected set editor(value: TextEditor) {
65+
this._editor = value;
66+
this._correlationKey = getEditorCorrelationKey(value);
67+
}
68+
69+
private _status: AnnotationStatus | undefined;
70+
get status(): AnnotationStatus | undefined {
71+
return this._status;
72+
}
73+
74+
get statusContextValue(): string | undefined {
75+
return this.status != null ? `${this.status}+${this.annotationType}` : undefined;
76+
}
77+
78+
private async setStatus(value: AnnotationStatus | undefined, editor: TextEditor | undefined): Promise<void> {
79+
if (this.status === value) return;
80+
81+
this._status = value;
82+
if (editor != null && editor === window.activeTextEditor) {
83+
await setContext('gitlens:annotationStatus', this.statusContextValue);
84+
}
85+
}
86+
6087
private onTextEditorSelectionChanged(e: TextEditorSelectionChangeEvent) {
61-
if (this.document !== e.textEditor.document) return;
88+
if (this.editor.document !== e.textEditor.document) return;
6289

63-
void this.selection({ line: e.selections[0].active.line });
90+
void this.selection?.({ line: e.selections[0].active.line });
6491
}
6592

66-
get editorUri(): Uri | undefined {
67-
return this.editor?.document?.uri;
93+
canReuse(_context?: TContext): boolean {
94+
return true;
6895
}
6996

7097
clear() {
98+
const decorations = this.decorations;
99+
this.decorations = undefined;
71100
this.annotationContext = undefined;
72-
this.status = undefined;
101+
void this.setStatus(undefined, this.editor);
102+
73103
if (this.editor == null) return;
74104

75-
if (this.decorations?.length) {
76-
for (const d of this.decorations) {
105+
if (decorations?.length) {
106+
for (const d of decorations) {
77107
try {
78108
this.editor.setDecorations(d.decorationType, []);
109+
if (d.dispose) {
110+
d.decorationType.dispose();
111+
}
79112
} catch {}
80113
}
81-
82-
this.decorations = undefined;
83114
}
84115
}
85116

86-
mustReopen(_context?: TContext): boolean {
117+
nextChange?(): void;
118+
previousChange?(): void;
119+
120+
async provideAnnotation(context?: TContext, force?: boolean): Promise<boolean> {
121+
void this.setStatus('computing', this.editor);
122+
123+
try {
124+
if (await this.onProvideAnnotation(context, force)) {
125+
void this.setStatus('computed', this.editor);
126+
await this.selection?.(force ? { line: this.editor.selection.active.line } : context?.selection);
127+
return true;
128+
}
129+
} catch (ex) {
130+
Logger.error(ex);
131+
}
132+
133+
void this.setStatus(undefined, this.editor);
87134
return false;
88135
}
89136

137+
protected abstract onProvideAnnotation(context?: TContext, force?: boolean): Promise<boolean>;
138+
90139
refresh(replaceDecorationTypes: Map<TextEditorDecorationType, TextEditorDecorationType | null>) {
91140
if (this.editor == null || !this.decorations?.length) return;
92141

@@ -106,65 +155,60 @@ export abstract class AnnotationProviderBase<TContext extends AnnotationContext
106155
this.setDecorations(this.decorations);
107156
}
108157

109-
async restore(editor: TextEditor) {
158+
restore(editor: TextEditor, force?: boolean) {
110159
// If the editor isn't disposed then we don't need to do anything
111160
// Explicitly check for `false`
112161
if ((this.editor as any)._disposed === false) return;
113162

114-
this.status = 'computing';
115-
if (editor === window.activeTextEditor) {
116-
await setContext('gitlens:annotationStatus', this.status);
163+
if (force || this.decorations == null) {
164+
void this.provideAnnotation(this.annotationContext, force);
165+
return;
117166
}
118167

168+
void this.setStatus('computing', this.editor);
169+
119170
this.editor = editor;
120-
this.correlationKey = getEditorCorrelationKey(editor);
121-
this.document = editor.document;
122171

123172
if (this.decorations?.length) {
124173
for (const d of this.decorations) {
125174
this.editor.setDecorations(d.decorationType, d.rangesOrOptions);
126175
}
127176
}
128177

129-
this.status = 'computed';
130-
if (editor === window.activeTextEditor) {
131-
await setContext('gitlens:annotationStatus', this.status);
132-
}
178+
void this.setStatus('computed', this.editor);
133179
}
134180

135-
async provideAnnotation(context?: TContext): Promise<boolean> {
136-
this.status = 'computing';
137-
try {
138-
if (await this.onProvideAnnotation(context)) {
139-
this.status = 'computed';
140-
return true;
141-
}
142-
} catch (ex) {
143-
Logger.error(ex);
144-
}
145-
146-
this.status = undefined;
147-
return false;
148-
}
181+
selection?(selection?: TContext['selection']): Promise<void>;
182+
validate?(): boolean | Promise<boolean>;
149183

150-
protected abstract onProvideAnnotation(context?: TContext): Promise<boolean>;
184+
protected setDecorations(decorations: Decoration[]) {
185+
if (this.decorations?.length) {
186+
// If we have no new decorations, just completely clear the old ones
187+
if (!decorations?.length) {
188+
this.clear();
151189

152-
abstract selection(selection?: TContext['selection']): Promise<void>;
190+
return;
191+
}
153192

154-
protected setDecorations(
155-
decorations: { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] | DecorationOptions[] }[],
156-
) {
157-
if (this.decorations?.length) {
158-
this.clear();
193+
// Only remove the decorations that are no longer needed
194+
const remove = this.decorations.filter(
195+
decoration => !decorations.some(d => d.decorationType.key === decoration.decorationType.key),
196+
);
197+
for (const d of remove) {
198+
try {
199+
this.editor.setDecorations(d.decorationType, []);
200+
if (d.dispose) {
201+
d.decorationType.dispose();
202+
}
203+
} catch {}
204+
}
159205
}
160206

161207
this.decorations = decorations;
162-
if (this.decorations?.length) {
163-
for (const d of this.decorations) {
208+
if (decorations?.length) {
209+
for (const d of decorations) {
164210
this.editor.setDecorations(d.decorationType, d.rangesOrOptions);
165211
}
166212
}
167213
}
168-
169-
abstract validate(): Promise<boolean>;
170214
}

src/annotations/annotations.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ export interface ComputedHeatmap {
2424
computeOpacity(date: Date): number;
2525
}
2626

27+
export type Decoration<T extends Range[] | DecorationOptions[] = Range[] | DecorationOptions[]> = {
28+
decorationType: TextEditorDecorationType;
29+
rangesOrOptions: T;
30+
dispose?: boolean;
31+
};
32+
2733
interface RenderOptions
2834
extends DecorationInstanceRenderOptions,
2935
ThemableDecorationRenderOptions,
@@ -93,7 +99,7 @@ export function addOrUpdateGutterHeatmapDecoration(
9399
date: Date,
94100
heatmap: ComputedHeatmap,
95101
range: Range,
96-
map: Map<string, { decorationType: TextEditorDecorationType; rangesOrOptions: Range[] }>,
102+
map: Map<string, Decoration<Range[]>>,
97103
) {
98104
const [r, g, b, a] = getHeatmapColor(date, heatmap);
99105

@@ -122,6 +128,7 @@ export function addOrUpdateGutterHeatmapDecoration(
122128
overviewRulerColor: scrollbar ? `rgba(${r},${g},${b},${a * 0.7})` : undefined,
123129
}),
124130
rangesOrOptions: [range],
131+
dispose: true,
125132
};
126133
map.set(key, colorDecoration);
127134
} else {

src/annotations/blameAnnotationProvider.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,14 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase
2121
protected hoverProviderDisposable: Disposable | undefined;
2222

2323
constructor(
24+
container: Container,
2425
annotationType: FileAnnotationType,
2526
editor: TextEditor,
2627
trackedDocument: TrackedDocument<GitDocumentState>,
27-
protected readonly container: Container,
2828
) {
29-
super(annotationType, editor, trackedDocument);
29+
super(container, annotationType, editor, trackedDocument);
3030

31-
this.blame = this.container.git.getBlame(this.trackedDocument.uri, editor.document);
31+
this.blame = container.git.getBlame(this.trackedDocument.uri, editor.document);
3232

3333
if (editor.document.isDirty) {
3434
trackedDocument.setForceDirtyStateChangeOnNextDocumentChange();
@@ -43,14 +43,17 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase
4343
super.clear();
4444
}
4545

46-
async validate(): Promise<boolean> {
46+
override async validate(): Promise<boolean> {
4747
const blame = await this.blame;
48-
return blame != null && blame.lines.length !== 0;
48+
return Boolean(blame?.lines.length);
4949
}
5050

51-
protected async getBlame(): Promise<GitBlame | undefined> {
51+
protected async getBlame(force?: boolean): Promise<GitBlame | undefined> {
52+
if (force) {
53+
this.blame = this.container.git.getBlame(this.trackedDocument.uri, this.editor.document);
54+
}
5255
const blame = await this.blame;
53-
if (blame == null || blame.lines.length === 0) return undefined;
56+
if (!blame?.lines.length) return undefined;
5457

5558
return blame;
5659
}
@@ -142,8 +145,9 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase
142145
return;
143146
}
144147

148+
this.hoverProviderDisposable?.dispose();
145149
this.hoverProviderDisposable = languages.registerHoverProvider(
146-
{ pattern: this.document.uri.fsPath },
150+
{ pattern: this.editor.document.uri.fsPath },
147151
{
148152
provideHover: (document: TextDocument, position: Position, token: CancellationToken) =>
149153
this.provideHover(providers, document, position, token),
@@ -159,7 +163,7 @@ export abstract class BlameAnnotationProviderBase extends AnnotationProviderBase
159163
): Promise<Hover | undefined> {
160164
if (configuration.get('hovers.annotations.over') !== 'line' && position.character !== 0) return undefined;
161165

162-
if (this.document.uri.toString() !== document.uri.toString()) return undefined;
166+
if (this.editor.document.uri.toString() !== document.uri.toString()) return undefined;
163167

164168
const blame = await this.getBlame();
165169
if (blame == null) return undefined;

0 commit comments

Comments
 (0)