feat(vcs): annotate either side of a diff with Git Blame (#1476)

Right-clicking a line or the line numbers of a diff pane now offers Annotate with
Git Blame / Close Annotations. Each side is blamed on its own history: a revision on
that revision (at the path the file had there), the index on HEAD with its own text,
and the working file like the editor's annotations, refreshed after edits and when
HEAD moves. Compare Files, Local History and plain-text sides don't offer it. The
divider between the panes, which had no menu, acts on the pane focused last.

Fixes #1443
This commit is contained in:
l0ng-ai
2026-11-05 14:03:46 +08:00
committed by GitHub
parent ee5d912157
commit 0d80e2c05a
3 changed files with 97 additions and 12 deletions
+37 -12
View File
@@ -28,7 +28,7 @@ import {
import { attachDocument, embeddedEditorExtensions, type DocumentAttachment } from "@/editor/api";
import { setFindOpen } from "@/editor/core/cm/find";
import { FindPanel, searchAgain, seedFind, type FindTarget } from "@/editor/core/view/FindBar";
import { setContext } from "@/platform/context";
import { getContext, setContext } from "@/platform/context";
import { call, commands } from "@/platform/ipc";
import { bindingsFor, normalizeBinding, shortcutLabel, strokeOf } from "@/platform/keymap";
import { tx } from "@/platform/i18n";
@@ -62,6 +62,8 @@ import {
type DiffSide,
} from "./diffInput";
import { diffAnnotations } from "./diffAnnotations";
import { annotationsShown, blameSlot, toggleAnnotations } from "./blame";
import { diffBlameTarget } from "./diffBlame";
import { changedChainNeighbour } from "./diffNavigation";
import { movedCodeHighlights } from "./movedCode";
import {
@@ -380,6 +382,7 @@ export function DiffEditor({ input, toolbarActions }: { input: EditorInput; tool
// Esc closes the find bar before it collapses the selection (a found match); open popups go first.
Prec.high(keymap.of([{ key: "Escape", run: () => findEscape.current() }])),
lang,
blameSlot(),
att?.extension ?? [],
editable ? [editableExtensions(), embeddedEditorExtensions(path)] : [],
notes
@@ -874,19 +877,41 @@ export function DiffEditor({ input, toolbarActions }: { input: EditorInput; tool
}),
]);
};
/** The panes' right-click menu: the contributions' items for the clicked line (none: the default menu). */
/** Annotate with Git Blame for a pane, by that side's own revision. */
const blameItems = (pane: DiffViewerPane) => {
const target = diffBlameTarget(pane.side === "left" ? left : right, path, !!getContext().vcsAvailable);
if (!target) return [];
const view = pane.view;
const text = target.revision ? view.state.doc.toString() : undefined;
return [
{
label: annotationsShown(view) ? "Close Annotations" : "Annotate with Git Blame",
run: () => void toggleAnnotations(view, target.path, text, target.revision),
},
];
};
/**
* The panes' right-click menu: the contributions' items for the clicked line and Git annotations
* (none: the default menu). The divider between the panes acts on the pane focused last.
*/
const onContextMenu = (e: MouseEvent) => {
const pane = viewerRef.current?.panes().find((p) => p.view.dom.contains(e.target as Node | null));
if (!pane) return;
const pos = pane.view.posAtCoords({ x: e.clientX, y: e.clientY }, false);
const line = pane.view.state.doc.lineAt(pos).number - 1;
const items = contribs.flatMap((c) => {
const list = c.contextMenuItems?.({ side: pane.side, line }) ?? [];
return list.length ? [...list, { kind: "separator" } as const] : [];
});
if (!items.length) return;
const viewer = viewerRef.current;
if (!viewer || e.defaultPrevented) return;
const hit = viewer.panes().find((p) => p.view.dom.contains(e.target as Node | null));
const pane = hit ?? viewer.focusedPane();
const line = hit
? hit.view.state.doc.lineAt(hit.view.posAtCoords({ x: e.clientX, y: e.clientY }, false)).number - 1
: -1;
const groups = [
...(hit ? contribs.map((c) => c.contextMenuItems?.({ side: hit.side, line }) ?? []) : []),
blameItems(pane),
].filter((g) => g.length);
if (!groups.length) return;
e.preventDefault();
showContextMenu(e, items.slice(0, -1));
showContextMenu(
e,
groups.flatMap((g, i) => (i ? [{ kind: "separator" } as const, ...g] : g)),
);
};
const viewerOf = () => viewerRef.current;
const nav = useRef<DiffNavigator>({
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { diffBlameTarget } from "./diffBlame";
describe("diffBlameTarget", () => {
it("blames a revision on its own history, at the path it had there", () => {
expect(diffBlameTarget({ type: "rev", rev: "abc123^" }, "/p/A.java", true)).toEqual({
path: "/p/A.java",
revision: "abc123^",
});
expect(diffBlameTarget({ type: "rev", rev: "abc123", path: "/p/Old.java" }, "/p/A.java", true)).toEqual({
path: "/p/Old.java",
revision: "abc123",
});
});
it("blames the index on HEAD and the working file like the editor", () => {
expect(diffBlameTarget({ type: "rev", rev: ":0" }, "/p/A.java", true)).toEqual({
path: "/p/A.java",
revision: "HEAD",
});
expect(diffBlameTarget({ type: "worktree" }, "/p/A.java", true)).toEqual({ path: "/p/A.java" });
});
it("offers nothing for content outside Git", () => {
for (const side of [
{ type: "file", path: "/p/B.java" },
{ type: "text", text: "x", label: "Clipboard" },
{ type: "history", id: "1", label: "Local History" },
] as const)
expect(diffBlameTarget(side, "/p/A.java", true)).toBeNull();
expect(diffBlameTarget({ type: "worktree" }, "/p/A.java", false)).toBeNull();
});
});
+27
View File
@@ -0,0 +1,27 @@
import type { DiffSide } from "./diffInput";
/** What `git blame` annotates a diff side with. */
export interface DiffBlameTarget {
/** The file at that side's revision (it may have been renamed since). */
path: string;
/** Blame history; unset for the working file, which follows HEAD and edits like the editor's annotations. */
revision?: string;
}
/**
* The blame for one side of a diff: a revision blames its own history, the working file the
* file on disk; Compare Files, Local History and plain texts are not Git content.
* The index (`:0`) is not a revision `git blame` takes, so its text is blamed on HEAD and the
* lines staged since show as not committed.
*/
export function diffBlameTarget(side: DiffSide, path: string, vcsAvailable: boolean): DiffBlameTarget | null {
if (!vcsAvailable) return null;
switch (side.type) {
case "rev":
return { path: side.path ?? path, revision: side.rev.startsWith(":") ? "HEAD" : side.rev };
case "worktree":
return { path };
default:
return null;
}
}