diff --git a/src/features/vcs/DiffEditor.tsx b/src/features/vcs/DiffEditor.tsx index b1b83e2d..d9c8dc58 100644 --- a/src/features/vcs/DiffEditor.tsx +++ b/src/features/vcs/DiffEditor.tsx @@ -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({ diff --git a/src/features/vcs/diffBlame.test.ts b/src/features/vcs/diffBlame.test.ts new file mode 100644 index 00000000..f6e7369a --- /dev/null +++ b/src/features/vcs/diffBlame.test.ts @@ -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(); + }); +}); diff --git a/src/features/vcs/diffBlame.ts b/src/features/vcs/diffBlame.ts new file mode 100644 index 00000000..cc8beeea --- /dev/null +++ b/src/features/vcs/diffBlame.ts @@ -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; + } +}