Skip to content

Commit 0a15c72

Browse files
committed
Adds experimental.allowAnnotationsWhenDirty setting
Allows for persistent file annotations even on dirty files Refs #1988, #3016
1 parent 5788d07 commit 0a15c72

File tree

7 files changed

+131
-65
lines changed

7 files changed

+131
-65
lines changed

package.json

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4007,6 +4007,50 @@
40074007
}
40084008
}
40094009
},
4010+
{
4011+
"id": "gitkraken",
4012+
"title": "GitKraken",
4013+
"order": 9000,
4014+
"properties": {
4015+
"gitlens.gitKraken.activeOrganizationId": {
4016+
"type": "string",
4017+
"markdownDescription": "Specifies the ID of the user's active GitKraken organization in GitLens",
4018+
"scope": "window",
4019+
"order": 1
4020+
}
4021+
}
4022+
},
4023+
{
4024+
"id": "experimental",
4025+
"title": "Experimental",
4026+
"order": 9500,
4027+
"properties": {
4028+
"gitlens.experimental.openChangesInMultiDiffEditor": {
4029+
"type": "boolean",
4030+
"default": false,
4031+
"markdownDescription": "(Experimental) Specifies whether to open multiple changes in VS Code's experimental multi-diff editor (single tab) or in individual diff editors (multiple tabs)",
4032+
"scope": "window",
4033+
"order": 10
4034+
},
4035+
"gitlens.experimental.allowAnnotationsWhenDirty": {
4036+
"type": "boolean",
4037+
"default": false,
4038+
"markdownDescription": "(Experimental) Specifies whether file annotations are allowed on dirty files. Use `#gitlens.advanced.blame.delayAfterEdit#` to control how long to wait before the annotation will refresh while still dirty",
4039+
"scope": "window",
4040+
"order": 20
4041+
},
4042+
"gitlens.experimental.nativeGit": {
4043+
"type": [
4044+
"boolean",
4045+
"null"
4046+
],
4047+
"default": true,
4048+
"markdownDescription": "(Experimental) Specifies whether to use Git directly for fetch/push/pull operation instead of relying on VS Code's built-in Git implementation",
4049+
"scope": "window",
4050+
"order": 30
4051+
}
4052+
}
4053+
},
40104054
{
40114055
"id": "advanced",
40124056
"title": "Advanced",
@@ -4202,7 +4246,7 @@
42024246
"gitlens.advanced.blame.delayAfterEdit": {
42034247
"type": "number",
42044248
"default": 5000,
4205-
"markdownDescription": "Specifies the time (in milliseconds) to wait before re-blaming an unsaved document after an edit. Use 0 to specify an infinite wait",
4249+
"markdownDescription": "Specifies the time (in milliseconds) to wait before re-blaming an unsaved document after an edit but before it is saved. Use 0 to specify an infinite wait",
42064250
"scope": "window",
42074251
"order": 42
42084252
},
@@ -4288,42 +4332,12 @@
42884332
"scope": "window",
42894333
"order": 110
42904334
},
4291-
"gitlens.experimental.nativeGit": {
4292-
"type": [
4293-
"boolean",
4294-
"null"
4295-
],
4296-
"default": true,
4297-
"markdownDescription": "(Experimental) Specifies whether to use Git directly for fetch/push/pull operation instead of relying on VS Code's built-in Git implementation",
4298-
"scope": "window",
4299-
"order": 120
4300-
},
4301-
"gitlens.experimental.openChangesInMultiDiffEditor": {
4302-
"type": "boolean",
4303-
"default": false,
4304-
"markdownDescription": "(Experimental) Specifies whether to open multiple changes in VS Code's experimental multi-diff editor (single tab) or in individual diff editors (multiple tabs)",
4305-
"scope": "window",
4306-
"order": 120
4307-
},
43084335
"gitlens.advanced.useSymmetricDifferenceNotation": {
43094336
"deprecationMessage": "Deprecated. This setting is no longer used",
43104337
"markdownDescription": "Deprecated. This setting is no longer used"
43114338
}
43124339
}
43134340
},
4314-
{
4315-
"id": "gitkraken",
4316-
"title": "GitKraken",
4317-
"order": 9000,
4318-
"properties": {
4319-
"gitlens.gitKraken.activeOrganizationId": {
4320-
"type": "string",
4321-
"markdownDescription": "Specifies the ID of the user's active GitKraken organization in GitLens",
4322-
"scope": "window",
4323-
"order": 1
4324-
}
4325-
}
4326-
},
43274341
{
43284342
"id": "general",
43294343
"title": "General",

src/annotations/annotationProvider.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import type { Decoration } from './annotations';
1111
export type AnnotationStatus = 'computing' | 'computed';
1212

1313
export interface AnnotationContext {
14-
selection?: { sha?: string; line?: undefined } | { sha?: undefined; line?: number } | false;
14+
selection?: { sha?: string; line?: never } | { sha?: never; line?: number } | false;
15+
}
16+
17+
export interface AnnotationState {
18+
recompute?: boolean;
19+
restoring?: boolean;
1520
}
1621

1722
export type TextEditorCorrelationKey = string;
@@ -48,7 +53,7 @@ export abstract class AnnotationProviderBase<TContext extends AnnotationContext
4853
get annotationContext(): TContext | undefined {
4954
return this._annotationContext;
5055
}
51-
protected set annotationContext(value: TContext | undefined) {
56+
private set annotationContext(value: TContext | undefined) {
5257
this._annotationContext = value;
5358
}
5459

@@ -117,13 +122,17 @@ export abstract class AnnotationProviderBase<TContext extends AnnotationContext
117122
nextChange?(): void;
118123
previousChange?(): void;
119124

120-
async provideAnnotation(context?: TContext, force?: boolean): Promise<boolean> {
125+
async provideAnnotation(context?: TContext, state?: AnnotationState): Promise<boolean> {
121126
void this.setStatus('computing', this.editor);
122127

123128
try {
124-
if (await this.onProvideAnnotation(context, force)) {
129+
this.annotationContext = context;
130+
131+
if (await this.onProvideAnnotation(context, state)) {
125132
void this.setStatus('computed', this.editor);
126-
await this.selection?.(force ? { line: this.editor.selection.active.line } : context?.selection);
133+
await this.selection?.(
134+
state?.restoring ? { line: this.editor.selection.active.line } : context?.selection,
135+
);
127136
return true;
128137
}
129138
} catch (ex) {
@@ -134,7 +143,7 @@ export abstract class AnnotationProviderBase<TContext extends AnnotationContext
134143
return false;
135144
}
136145

137-
protected abstract onProvideAnnotation(context?: TContext, force?: boolean): Promise<boolean>;
146+
protected abstract onProvideAnnotation(context?: TContext, state?: AnnotationState): Promise<boolean>;
138147

139148
refresh(replaceDecorationTypes: Map<TextEditorDecorationType, TextEditorDecorationType | null>) {
140149
if (this.editor == null || !this.decorations?.length) return;
@@ -155,20 +164,20 @@ export abstract class AnnotationProviderBase<TContext extends AnnotationContext
155164
this.setDecorations(this.decorations);
156165
}
157166

158-
restore(editor: TextEditor, force?: boolean) {
167+
restore(editor: TextEditor, recompute?: boolean) {
159168
// If the editor isn't disposed then we don't need to do anything
160169
// Explicitly check for `false`
161170
if ((this.editor as any)._disposed === false) return;
162171

163-
if (force || this.decorations == null) {
164-
void this.provideAnnotation(this.annotationContext, force);
172+
this.editor = editor;
173+
174+
if (recompute || this.decorations == null) {
175+
void this.provideAnnotation(this.annotationContext, { recompute: true, restoring: true });
165176
return;
166177
}
167178

168179
void this.setStatus('computing', this.editor);
169180

170-
this.editor = editor;
171-
172181
if (this.decorations?.length) {
173182
for (const d of this.decorations) {
174183
this.editor.setDecorations(d.decorationType, d.rangesOrOptions);

src/annotations/fileAnnotationController.ts

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,18 @@ import { registerCommand } from '../system/command';
2727
import { configuration } from '../system/configuration';
2828
import { setContext } from '../system/context';
2929
import { once } from '../system/event';
30+
import type { Deferrable } from '../system/function';
3031
import { debounce } from '../system/function';
3132
import { find } from '../system/iterable';
3233
import type { KeyboardScope } from '../system/keyboard';
3334
import { Logger } from '../system/logger';
3435
import { basename } from '../system/path';
3536
import { isTextEditor } from '../system/utils';
36-
import type { DocumentBlameStateChangeEvent, DocumentDirtyStateChangeEvent } from '../trackers/documentTracker';
37+
import type {
38+
DocumentBlameStateChangeEvent,
39+
DocumentDirtyIdleTriggerEvent,
40+
DocumentDirtyStateChangeEvent,
41+
} from '../trackers/documentTracker';
3742
import type { GitDocumentState } from '../trackers/gitDocumentTracker';
3843
import type { AnnotationContext, AnnotationProviderBase, TextEditorCorrelationKey } from './annotationProvider';
3944
import { getEditorCorrelationKey } from './annotationProvider';
@@ -185,16 +190,35 @@ export class FileAnnotationController implements Disposable {
185190
if (editor == null) return;
186191

187192
// Only care if we are becoming un-blameable
188-
if (e.blameable) return;
193+
if (e.blameable) {
194+
if (configuration.get('experimental.allowAnnotationsWhenDirty')) {
195+
this.restore(editor);
196+
}
197+
198+
return;
199+
}
189200

190201
void this.clear(editor, 'BlameabilityChanged');
191202
}
192203

204+
private onDirtyIdleTriggered(e: DocumentDirtyIdleTriggerEvent<GitDocumentState>) {
205+
if (!e.document.isBlameable || !configuration.get('experimental.allowAnnotationsWhenDirty')) return;
206+
207+
const editor = window.activeTextEditor;
208+
if (editor == null) return;
209+
210+
this.restore(editor);
211+
}
212+
193213
private onDirtyStateChanged(e: DocumentDirtyStateChangeEvent<GitDocumentState>) {
194214
for (const [key, p] of this._annotationProviders) {
195215
if (!e.document.is(p.editor.document)) continue;
196216

197-
void this.clearCore(key, 'DocumentChanged');
217+
if (e.dirty) {
218+
void this.clearCore(key, 'DocumentChanged');
219+
} else if (configuration.get('experimental.allowAnnotationsWhenDirty')) {
220+
this.restore(e.editor);
221+
}
198222
}
199223
}
200224

@@ -229,7 +253,7 @@ export class FileAnnotationController implements Disposable {
229253

230254
private onVisibleTextEditorsChanged(editors: readonly TextEditor[]) {
231255
for (const e of editors) {
232-
this.getProvider(e)?.restore(e);
256+
this.getProvider(e)?.restore(e, false);
233257
}
234258
}
235259

@@ -273,6 +297,24 @@ export class FileAnnotationController implements Disposable {
273297
return this._annotationProviders.get(getEditorCorrelationKey(editor));
274298
}
275299

300+
private debouncedRestores = new WeakMap<TextEditor, Deferrable<AnnotationProviderBase['restore']>>();
301+
302+
private restore(editor: TextEditor, recompute?: boolean) {
303+
const provider = this.getProvider(editor);
304+
if (provider == null) return;
305+
306+
let debouncedRestore = this.debouncedRestores.get(editor);
307+
if (debouncedRestore == null) {
308+
debouncedRestore = debounce((editor: TextEditor) => {
309+
this.debouncedRestores.delete(editor);
310+
provider.restore(editor, recompute ?? true);
311+
}, 500);
312+
this.debouncedRestores.set(editor, debouncedRestore);
313+
}
314+
315+
debouncedRestore(editor);
316+
}
317+
276318
async show(editor: TextEditor | undefined, type: FileAnnotationType, context?: AnnotationContext): Promise<boolean>;
277319
async show(editor: TextEditor | undefined, type: 'changes', context?: ChangesAnnotationContext): Promise<boolean>;
278320
async show(
@@ -406,8 +448,13 @@ export class FileAnnotationController implements Disposable {
406448

407449
Logger.log(`${reason}:`, `Clear annotations for ${key}`);
408450

409-
this._annotationProviders.delete(key);
410-
provider.dispose();
451+
if (
452+
!configuration.get('experimental.allowAnnotationsWhenDirty') ||
453+
(reason !== 'BlameabilityChanged' && reason !== 'DocumentChanged')
454+
) {
455+
this._annotationProviders.delete(key);
456+
provider.dispose();
457+
}
411458

412459
if (!this._annotationProviders.size || key === getEditorCorrelationKey(this._editor)) {
413460
await setContext('gitlens:annotationStatus', undefined);
@@ -504,6 +551,7 @@ export class FileAnnotationController implements Disposable {
504551
workspace.onDidCloseTextDocument(this.onTextDocumentClosed, this),
505552
this.container.tracker.onDidChangeBlameState(this.onBlameStateChanged, this),
506553
this.container.tracker.onDidChangeDirtyState(this.onDirtyStateChanged, this),
554+
this.container.tracker.onDidTriggerDirtyIdle(this.onDirtyIdleTriggered, this),
507555
registerCommand('gitlens.annotations.nextChange', () => this.nextChange()),
508556
registerCommand('gitlens.annotations.previousChange', () => this.previousChange()),
509557
);
@@ -586,7 +634,7 @@ export class FileAnnotationController implements Disposable {
586634
`data:image/svg+xml,${encodeURIComponent(
587635
`<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18'><rect fill='rgb(${addedColor.join(
588636
',',
589-
)})' x='15' y='0' width='3' height='18'/></svg>`,
637+
)})' x='13' y='0' width='3' height='18'/></svg>`,
590638
)}`,
591639
)
592640
: undefined,
@@ -605,7 +653,7 @@ export class FileAnnotationController implements Disposable {
605653
`data:image/svg+xml,${encodeURIComponent(
606654
`<svg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18'><rect fill='rgb(${changedColor.join(
607655
',',
608-
)})' x='15' y='0' width='3' height='18'/></svg>`,
656+
)})' x='13' y='0' width='3' height='18'/></svg>`,
609657
)}`,
610658
)
611659
: undefined,

src/annotations/gutterBlameAnnotationProvider.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import type { TokenOptions } from '../system/string';
1616
import { getTokensFromTemplate, getWidth } from '../system/string';
1717
import type { GitDocumentState } from '../trackers/gitDocumentTracker';
1818
import type { TrackedDocument } from '../trackers/trackedDocument';
19-
import type { AnnotationContext } from './annotationProvider';
19+
import type { AnnotationContext, AnnotationState } from './annotationProvider';
2020
import { applyHeatmap, getGutterDecoration, getGutterRenderOptions } from './annotations';
2121
import { BlameAnnotationProviderBase } from './blameAnnotationProvider';
2222
import { Decorations } from './fileAnnotationController';
@@ -39,12 +39,10 @@ export class GutterBlameAnnotationProvider extends BlameAnnotationProviderBase {
3939
}
4040

4141
@log()
42-
override async onProvideAnnotation(context?: AnnotationContext, force?: boolean): Promise<boolean> {
42+
override async onProvideAnnotation(context?: AnnotationContext, state?: AnnotationState): Promise<boolean> {
4343
const scope = getLogScope();
4444

45-
this.annotationContext = context;
46-
47-
const blame = await this.getBlame(force);
45+
const blame = await this.getBlame(state?.recompute);
4846
if (blame == null) return false;
4947

5048
using sw = maybeStopWatch(scope);

src/annotations/gutterChangesAnnotationProvider.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { getSettledValue } from '../system/promise';
1111
import { maybeStopWatch } from '../system/stopwatch';
1212
import type { GitDocumentState } from '../trackers/gitDocumentTracker';
1313
import type { TrackedDocument } from '../trackers/trackedDocument';
14-
import type { AnnotationContext } from './annotationProvider';
14+
import type { AnnotationContext, AnnotationState } from './annotationProvider';
1515
import { AnnotationProviderBase } from './annotationProvider';
1616
import type { Decoration } from './annotations';
1717
import { Decorations } from './fileAnnotationController';
@@ -95,11 +95,9 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase<Chan
9595
}
9696

9797
@log()
98-
override async onProvideAnnotation(context?: ChangesAnnotationContext, _force?: boolean): Promise<boolean> {
98+
override async onProvideAnnotation(context?: ChangesAnnotationContext, state?: AnnotationState): Promise<boolean> {
9999
const scope = getLogScope();
100100

101-
this.annotationContext = context;
102-
103101
let ref1 = this.trackedDocument.uri.sha;
104102
let ref2 = context?.sha != null && context.sha !== ref1 ? `${context.sha}^` : undefined;
105103

@@ -263,7 +261,7 @@ export class GutterChangesAnnotationProvider extends AnnotationProviderBase<Chan
263261

264262
sw?.stop({ suffix: ' to apply all recent changes annotations' });
265263

266-
if (selection != null && context?.selection !== false) {
264+
if (selection != null && context?.selection !== false && !state?.restoring) {
267265
this.editor.selection = selection;
268266
this.editor.revealRange(selection, TextEditorRevealType.InCenterIfOutsideViewport);
269267
}

src/annotations/gutterHeatmapBlameAnnotationProvider.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { getLogScope } from '../system/logger.scope';
77
import { maybeStopWatch } from '../system/stopwatch';
88
import type { GitDocumentState } from '../trackers/gitDocumentTracker';
99
import type { TrackedDocument } from '../trackers/trackedDocument';
10-
import type { AnnotationContext } from './annotationProvider';
10+
import type { AnnotationContext, AnnotationState } from './annotationProvider';
1111
import type { Decoration } from './annotations';
1212
import { addOrUpdateGutterHeatmapDecoration } from './annotations';
1313
import { BlameAnnotationProviderBase } from './blameAnnotationProvider';
@@ -18,12 +18,10 @@ export class GutterHeatmapBlameAnnotationProvider extends BlameAnnotationProvide
1818
}
1919

2020
@log()
21-
override async onProvideAnnotation(context?: AnnotationContext, force?: boolean): Promise<boolean> {
21+
override async onProvideAnnotation(context?: AnnotationContext, state?: AnnotationState): Promise<boolean> {
2222
const scope = getLogScope();
2323

24-
this.annotationContext = context;
25-
26-
const blame = await this.getBlame(force);
24+
const blame = await this.getBlame(state?.recompute);
2725
if (blame == null) return false;
2826

2927
using sw = maybeStopWatch(scope);

0 commit comments

Comments
 (0)