Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion apps/extension/src/blame/blameController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as vscode from "vscode";
import { relative } from "node:path";
import type { BlameResult, BlameCommit } from "@gitstudio/git-service/index";
import { UNCOMMITTED_SHA } from "@gitstudio/git-service/index";
import { BlameNotCommittedError } from "@gitstudio/git-service/BlameProvider";
import type { RepoManager, RepoEntry } from "../git/repoManager";
import { touches } from "../git/repoChange";
import { notice, notifyCopied } from "../ui/notify";
Expand Down Expand Up @@ -49,6 +50,14 @@ export class BlameController implements vscode.Disposable {
string,
{ version: number; result: Promise<BlameResult | undefined> }
>();
/**
* Documents git cannot blame because they are not in HEAD (untracked).
* Not asked again — every edit used to spawn a blame that failed and
* logged it (#115) — until git reports a change in the repository.
*/
private readonly notInHead = new Set<string>();
/** Bumped on every repository change, so an answer from before it is not kept. */
private gitEpoch = 0;

// Full-file annotation state, per editor (by document uri string).
private readonly annotated = new Map<string, vscode.TextEditorDecorationType>();
Expand Down Expand Up @@ -146,6 +155,7 @@ export class BlameController implements vscode.Disposable {
vscode.workspace.onDidCloseTextDocument((doc) => {
const key = doc.uri.toString();
this.blameCache.delete(key);
this.notInHead.delete(key);
// Annotate mode creates a decoration type per opened file; without this
// the map (and its types) grow for every file visited in the session.
const type = this.annotated.get(key);
Expand Down Expand Up @@ -215,6 +225,13 @@ export class BlameController implements vscode.Disposable {
// checkout, a rebase), not when a file is saved — a file's own edits
// already drop its entry (keyed by document version, above). It used
// to drop EVERY file's blame on each save and window focus.
// Any change git reports — a commit, or a `git add`, after which git
// blames the file from the index — may have put a file that was not in
// HEAD where blame can see it: ask again once, not on every keystroke.
this.repos.onDidChange(() => {
this.notInHead.clear();
this.gitEpoch++;
}),
this.repos.onDidChange((e) => {
if (!touches(e, "refs", "operation", "repos")) {
return;
Expand Down Expand Up @@ -802,6 +819,9 @@ export class BlameController implements vscode.Disposable {
return undefined;
}
const key = document.uri.toString();
if (this.notInHead.has(key)) {
return undefined;
}
const cached = this.blameCache.get(key);
if (cached && cached.version === document.version) {
return cached.result;
Expand All @@ -814,11 +834,15 @@ export class BlameController implements vscode.Disposable {
}
// Feed the live (possibly dirty) buffer so blame matches what's on screen.
const contents = document.isDirty ? document.getText() : undefined;
const epoch = this.gitEpoch;

const promise = ctx.ctx.blame
.blameFile(relPath, { contents, signal: controller.signal })
.catch((e: unknown) => {
if (!controller.signal.aborted) {
if (e instanceof BlameNotCommittedError) {
// Expected, not a failure: there is no history to show yet.
if (epoch === this.gitEpoch) this.notInHead.add(key);
} else if (!controller.signal.aborted) {
const msg = e instanceof Error ? e.message : String(e);
this.log?.(l10n.t("blame failed for {0}: {1}", relPath, msg));
console.error("[GitStudio] blame failed", e);
Expand Down
109 changes: 97 additions & 12 deletions apps/extension/src/changes/stagedGutter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import * as vscode from "vscode";
import { stat } from "node:fs/promises";
import { listChangeBlocks, setBlockStaged } from "@gitstudio/git-service/blockStaging";
import type { ChangeBlock } from "@gitstudio/git-service/blockStaging";
import {
changeBlocksFrom,
isStageableText,
readBaselines,
setBlockStaged,
} from "@gitstudio/git-service/blockStaging";
import type { Baselines, ChangeBlock } from "@gitstudio/git-service/blockStaging";
import type { RepoManager, RepoEntry } from "../git/repoManager";
import { relativePath } from "./changesView";
import { blockAtLine } from "./blockAtLine";
Expand Down Expand Up @@ -59,6 +64,21 @@ export class StagedGutter implements vscode.Disposable {
* codebase keeps hitting.
*/
private readonly generation = new Map<string, number>();
/**
* What git said about each document when it was last read: whether it is
* conflicted, and its HEAD and index text. An EDIT moves neither, so a
* refresh caused only by typing diffs the buffer against these instead of
* spawning `git status` and two `git show`s per pause in typing (#115).
* Every other cause — focus, save, a ref or index move, a toggle — reads git
* again and replaces the entry.
*/
private readonly baselines = new Map<string, { conflicted: boolean; baselines: Baselines }>();
/** Documents whose next scheduled refresh must read git, not reuse `baselines`. */
private readonly readGit = new Set<string>();
/** Documents with a scheduled refresh running now; a timer that fires meanwhile waits for it. */
private readonly running = new Map<string, Promise<void>>();
/** The document version each document's marks were last painted for. */
private readonly paintedVersion = new Map<string, number>();
private enabled: boolean;

constructor(
Expand Down Expand Up @@ -99,8 +119,10 @@ export class StagedGutter implements vscode.Disposable {
if (editor) void this.schedule(editor.document, 0);
}),
vscode.workspace.onDidChangeTextDocument((event) => {
void this.schedule(event.document, REFRESH_DEBOUNCE_MS);
// Typing: same HEAD, same index — re-diff, do not re-read git.
void this.schedule(event.document, REFRESH_DEBOUNCE_MS, false);
}),
vscode.workspace.onDidCloseTextDocument((doc) => this.forget(doc.uri.toString())),
vscode.workspace.onDidSaveTextDocument((doc) => void this.schedule(doc, 0)),
vscode.window.onDidChangeTextEditorSelection((event) =>
this.onPossibleGutterClick(event),
Expand Down Expand Up @@ -181,40 +203,84 @@ export class StagedGutter implements vscode.Disposable {
}
}

private schedule(document: vscode.TextDocument, delay: number): void {
/**
* Refreshes `document` after `delay`, coalescing: a newer call replaces a
* pending one, and a refresh never starts while the last one for the same
* document is still running — it runs once that one is done. `readGit`
* false means only the buffer changed; any true call before the refresh
* runs makes it read git.
*/
private schedule(document: vscode.TextDocument, delay: number, readGit = true): void {
if (document.uri.scheme !== "file") return;
const key = document.uri.toString();
if (readGit) this.readGit.add(key);
const existing = this.timers.get(key);
if (existing) clearTimeout(existing);
this.timers.set(
key,
setTimeout(() => {
this.timers.delete(key);
void this.refresh(document);
void this.runScheduled(document);
}, delay),
);
}

private async refresh(document: vscode.TextDocument): Promise<void> {
private async runScheduled(document: vscode.TextDocument): Promise<void> {
const key = document.uri.toString();
const inFlight = this.running.get(key);
if (inFlight) {
// Run once it is done — unless something newer has been scheduled by then.
await inFlight;
if (this.timers.has(key) || this.running.has(key)) return;
}
const readGit = this.readGit.delete(key) || !this.baselines.has(key);
// Only the buffer could have changed, and it has not: nothing to redo.
if (!readGit && typeof document.version === "number" && this.paintedVersion.get(key) === document.version) {
return;
}
const run = this.refresh(document, !readGit).finally(() => {
if (this.running.get(key) === run) this.running.delete(key);
});
this.running.set(key, run);
await run;
}

private async refresh(document: vscode.TextDocument, reuseBaselines = false): Promise<void> {
const key = document.uri.toString();
const gen = (this.generation.get(key) ?? 0) + 1;
this.generation.set(key, gen);

const target = this.resolve(document.uri);
if (!target || !this.enabled) {
this.blocksByDoc.delete(key);
this.baselines.delete(key);
this.paintedVersion.delete(key);
this.paint(document, []);
return;
}

const blocks = await this.readBlocks(target, document);
const version = document.version;
const blocks = await this.readBlocks(target, document, reuseBaselines);
// A newer refresh started while git was answering; its answer wins.
if (this.generation.get(key) !== gen) return;

this.blocksByDoc.set(key, blocks);
if (typeof version === "number") this.paintedVersion.set(key, version);
else this.paintedVersion.delete(key);
this.paint(document, blocks);
}

private forget(key: string): void {
const timer = this.timers.get(key);
if (timer) clearTimeout(timer);
this.timers.delete(key);
this.blocksByDoc.delete(key);
this.baselines.delete(key);
this.readGit.delete(key);
this.paintedVersion.delete(key);
this.generation.delete(key);
}

private paint(document: vscode.TextDocument, blocks: ChangeBlock[]): void {
const editors = vscode.window.visibleTextEditors.filter(
(e) => e.document.uri.toString() === document.uri.toString(),
Expand Down Expand Up @@ -345,17 +411,35 @@ export class StagedGutter implements vscode.Disposable {
await this.toggleAtLine(document, line);
}

/** The document's change blocks as git has them NOW; none for a conflicted file. */
/**
* The document's change blocks as git has them NOW; none for a conflicted
* file. `reuseBaselines`: git was read for this document since anything but
* its text changed, so diff the text against what it said then.
*/
private async readBlocks(
target: { entry: RepoEntry; rel: string },
document: vscode.TextDocument,
reuseBaselines = false,
): Promise<ChangeBlock[]> {
const key = document.uri.toString();
// Binary or huge: no ticks, and no reason to ask git anything.
if (!isStageableText(document.getText())) return [];
try {
// A conflicted file has no stage-0 index entry, so the tick model would
// read every change as unstaged. Say nothing rather than something wrong.
const conflicted = await target.entry.ctx.conflict.isConflicted(target.rel);
return conflicted ? [] : await listChangeBlocks(target.entry.ctx, target.rel, document.getText());
let known = reuseBaselines ? this.baselines.get(key) : undefined;
if (!known) {
// A conflicted file has no stage-0 index entry, so the tick model would
// read every change as unstaged. Say nothing rather than something wrong.
const conflicted = await target.entry.ctx.conflict.isConflicted(target.rel);
const baselines = conflicted
? { head: "", index: "" }
: await readBaselines(target.entry.ctx, target.rel);
known = { conflicted, baselines };
this.baselines.set(key, known);
}
// The text as it is now, after git answered — not as it was when asked.
return known.conflicted ? [] : changeBlocksFrom(known.baselines, document.getText());
} catch {
this.baselines.delete(key);
return [];
}
}
Expand All @@ -377,6 +461,7 @@ export class StagedGutter implements vscode.Disposable {
dispose(): void {
for (const timer of this.timers.values()) clearTimeout(timer);
this.timers.clear();
this.baselines.clear();
for (const decoration of this.decorations.values()) decoration.dispose();
this.decorations.clear();
for (const disposable of this.disposables) disposable.dispose();
Expand Down
56 changes: 53 additions & 3 deletions apps/extension/test/blameController.surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,16 +213,66 @@ test("a file outside every repository, or not on disk, or too long to blame, get
test("a blame git cannot run is logged, and the line is left bare", async () => {
const s = setup();
const logged: string[] = [];
const e = editor(doc(join(s.r.dir, "never-committed.txt"), { text: "x\n" }), 0);
e.document.isDirty = false;
const blame = s.r.ctx.blame as unknown as { blameFile: (...a: unknown[]) => Promise<unknown> };
blame.blameFile = () => Promise.reject(new Error("git blame failed for f.txt (exit 128): fatal: bad object"));
const e = editor(doc(s.file), 0);
focus(e);
const ui = start(s, (m) => logged.push(m));
await kit.until(() => logged.length, "logged");
assert.match(logged[0], /^blame failed for never-committed\.txt: /);
assert.match(logged[0], /^blame failed for f\.txt: /);
await kit.until(() => e.painted.has(ui.inline.id), "cleared");
assert.deepEqual(e.painted.get(ui.inline.id), []);
});

test("a file not in HEAD is blamed once, quietly — not on every edit — until a commit adds it (#115)", async () => {
const s = setup();
const logged: string[] = [];
const blame = s.r.ctx.blame as unknown as { blameFile: (...a: unknown[]) => Promise<unknown> };
const real = blame.blameFile.bind(blame);
let asked = 0;
blame.blameFile = (...a: unknown[]) => {
asked++;
return real(...a);
};
const path = join(s.r.dir, "never-committed.txt");
s.r.write("never-committed.txt", "x\n");
let d = doc(path, { text: "x\n" });
const e = editor(d, 0);
focus(e);
const ui = start(s, (m) => logged.push(m));
await kit.until(() => asked === 1 && e.painted.has(ui.inline.id), "the first look");
// Typing: every edit is a new version, and each moves the caret.
for (let v = 2; v <= 6; v++) {
d = doc(path, { text: "x".repeat(v) + "\n" });
d.version = v;
e.document = d;
kit.editorEvents.docChanged.fire({ document: d });
kit.editorEvents.selection.fire({ textEditor: e, kind: 1, selections: [e.selection] });
// The hover asks too.
await ui.c.getBlame(d as never, s.entry as never);
}
// Let the caret's debounced re-render run.
e.painted.delete(ui.inline.id);
e.selection = new kit.Selection(new kit.Position(0, 1), new kit.Position(0, 1));
kit.editorEvents.selection.fire({ textEditor: e, kind: 1, selections: [e.selection] });
await kit.until(() => e.painted.has(ui.inline.id), "re-rendered");
assert.equal(asked, 1, "git blame is not asked again for a file it said is not in HEAD");
assert.deepEqual(logged, [], "and nothing is logged: an untracked file is not a failure");
assert.deepEqual(e.painted.get(ui.inline.id), []);

// A commit adds it: the next look blames it.
commitAt(s.r, "never-committed.txt", "x\n", "add it", "2022-01-01T00:00:00Z", "Grace Hopper <g@h.i>");
s.repos.fire(repoChange(["refs"]));
d = doc(path);
d.version = 7;
e.document = d;
e.selection = new kit.Selection(new kit.Position(0, 0), new kit.Position(0, 0));
kit.editorEvents.selection.fire({ textEditor: e, kind: 1, selections: [e.selection] });
await kit.until(() => /Grace Hopper/.test(ui.bar.text), "blamed after the commit");
assert.equal(asked, 2);
assert.deepEqual(logged, []);
});

test("a commit elsewhere drops the cached blame and repaints the line", async () => {
const s = setup();
const e = editor(doc(s.file), 0);
Expand Down
Loading
Loading