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