mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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>({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user