From 67e3c60db85644d79a52dd9989ddb6de3068cc89 Mon Sep 17 00:00:00 2001 From: arookieofc Date: Fri, 30 Oct 2026 19:42:05 +0800 Subject: [PATCH] feat(vcs): blame each merge side pane on the branch it belongs to (#391) The merge view blamed both side panes against HEAD, so lines that only exist on the merged branch showed as Not Committed Yet. vcs_blame takes an optional revision (validated, passed to git blame --contents -); the merge view blames Yours on HEAD and Theirs on MERGE_HEAD (swapped during a rebase). Co-authored-by: a-rookie-of-C-language <186315048+a-rookie-of-C-language@users.noreply.github.com> --- crates/ide-host/src/ops/vcs/commands.rs | 16 +++++++++-- crates/ide-host/tests/vcs_encoding.rs | 2 +- crates/ide-vcs/src/lib.rs | 5 +++- crates/ide-vcs/src/write.rs | 9 ++++-- crates/ide-vcs/tests/git_workflows.rs | 2 +- crates/ide-vcs/tests/repo.rs | 37 ++++++++++++++++++++++--- src-tauri/src/commands/vcs.rs | 3 +- src/bindings.ts | 2 +- src/editor/lsp/authorLens.ts | 2 +- src/features/vcs/MergeEditor.tsx | 11 +++++--- src/features/vcs/blame.ts | 9 +++--- 11 files changed, 75 insertions(+), 23 deletions(-) diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 7d3861665..68941b0a4 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -201,12 +201,20 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option) -> IdeResult> { +pub async fn vcs_blame( + host: &Host, + window: &str, + path: PathBuf, + text: Option, + revision: Option, +) -> IdeResult> { let project = host.project(window)?; // An explicit text wins (merge view annotates its side panes, not the worktree). let text = text.or_else(|| project.documents.text_of(&path)); blocking(move || { - project.with_repo_of(&path, |r| r.blame(&path, text.as_deref(), project.vcs_encoding(&path, None)?)) + project.with_repo_of(&path, |r| { + r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?) + }) }) .await } @@ -837,7 +845,9 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await }); d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) }); d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await }); - d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?).await }); + d.add("vcs_blame", |c| async move { + vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await + }); d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await }); d.add("vcs_commit_files", |c| async move { vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await diff --git a/crates/ide-host/tests/vcs_encoding.rs b/crates/ide-host/tests/vcs_encoding.rs index 32749e12e..65104008a 100644 --- a/crates/ide-host/tests/vcs_encoding.rs +++ b/crates/ide-host/tests/vcs_encoding.rs @@ -96,7 +96,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() { project.documents.save(doc.id).unwrap(); assert_eq!(std::fs::read(&path).unwrap(), original); assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty()); - assert!(vcs::vcs_blame(&host, "main", path.clone(), None).await.unwrap().iter().all(|l| l.author == "Test")); + assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test")); // Missing working files still use configured encoding for historical content. project.documents.close(doc.id); let mut defaults = project.documents.file_defaults(); diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 30b5b414d..1c3509bc3 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -591,13 +591,16 @@ impl Repo { } /// One entry per line of the current text (`current_text`, or the file on disk). /// Lines not yet committed carry an all-zero commit id and author "Not Committed Yet". + /// `revision` is the history to blame against (default `HEAD`); the merge view + /// blames each side pane on the branch it belongs to (e.g. `MERGE_HEAD`). pub fn blame( &self, path: &Path, current_text: Option<&str>, + revision: Option<&str>, encoding: &'static Encoding, ) -> IdeResult> { - write::blame(self, path, current_text, encoding) + write::blame(self, path, current_text, revision, encoding) } /// Commits newest-first in topological order (children before parents). /// diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 7b2747db2..7e0362c55 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -47,9 +47,14 @@ pub(crate) fn blame( r: &Repo, path: &Path, current_text: Option<&str>, + revision: Option<&str>, encoding: &'static Encoding, ) -> IdeResult> { let rel = r.rel_path(path)?; + let revision = match revision { + Some(rev) => check_rev(rev)?, + None => "HEAD", + }; let disk = match std::fs::read(r.abs_path(&rel)) { Ok(bytes) => bytes, Err(_) if current_text.is_some() => Vec::new(), @@ -74,14 +79,14 @@ pub(crate) fn blame( let line_count = text.split_inclusive('\n').count(); let tracked_in_head = { let repo = r.gix()?; - crate::repo::blob_at(&repo, &rel, "HEAD")?.is_some() + crate::repo::blob_at(&repo, &rel, revision)?.is_some() }; if !tracked_in_head { return Ok(uncommitted_lines(line_count)); } let out = run( r.root(), - &["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", "--", &rel], + &["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel], Some(&contents), )?; let mut lines = crate::blame::parse_porcelain(&out.stdout_str()); diff --git a/crates/ide-vcs/tests/git_workflows.rs b/crates/ide-vcs/tests/git_workflows.rs index e33706286..00552a0bc 100644 --- a/crates/ide-vcs/tests/git_workflows.rs +++ b/crates/ide-vcs/tests/git_workflows.rs @@ -777,7 +777,7 @@ fn add_to_vcs_annotate_and_history() { repo.stage(&[fx.path("new/b.txt")]).unwrap(); assert_eq!(status_of(&repo.status().unwrap(), "new/b.txt").unwrap().staged, Some(ChangeKind::Added)); - let blame = repo.blame(&fx.path("a.txt"), Some("1\nchanged\nunsaved\n"), encoding_rs::UTF_8).unwrap(); + let blame = repo.blame(&fx.path("a.txt"), Some("1\nchanged\nunsaved\n"), None, encoding_rs::UTF_8).unwrap(); assert_eq!(blame.len(), 3); assert_eq!(blame[0].commit, first); assert_eq!(blame[1].commit, second); diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index 99beb12e0..33588d273 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -441,7 +441,7 @@ fn blame_with_unsaved_edits() { let second = fx.commit_all("second"); let repo = fx.repo(); - let lines = repo.blame(&fx.path("b.txt"), None, encoding_rs::UTF_8).unwrap(); + let lines = repo.blame(&fx.path("b.txt"), None, None, encoding_rs::UTF_8).unwrap(); assert_eq!(lines.len(), 3); assert_eq!(lines[0].commit, first); assert_eq!(lines[1].commit, second); @@ -449,7 +449,7 @@ fn blame_with_unsaved_edits() { assert_eq!(lines[0].author, "Test User"); assert!(lines[0].time_ms > 1.0e12); - let lines = repo.blame(&fx.path("b.txt"), Some("one\nedited\nthree\nfour"), encoding_rs::UTF_8).unwrap(); + let lines = repo.blame(&fx.path("b.txt"), Some("one\nedited\nthree\nfour"), None, encoding_rs::UTF_8).unwrap(); assert_eq!(lines.len(), 4); assert_eq!(lines[0].commit, first); assert!(lines[1].commit.bytes().all(|b| b == b'0')); @@ -457,11 +457,40 @@ fn blame_with_unsaved_edits() { assert_eq!(lines[2].commit, first); assert_eq!(lines[3].author, "Not Committed Yet"); - let lines = repo.blame(&fx.path("untracked.txt"), Some("x\ny\n"), encoding_rs::UTF_8).unwrap(); + let lines = repo.blame(&fx.path("untracked.txt"), Some("x\ny\n"), None, encoding_rs::UTF_8).unwrap(); assert_eq!(lines.len(), 2); assert!(lines.iter().all(|l| l.author == "Not Committed Yet")); } +#[test] +fn blame_against_another_revision() { + // The merge view blames each side pane on the branch it belongs to: lines that + // only exist on the merged branch show their real history, not "Not Committed Yet". + let fx = Fixture::new(); + fx.write("b.txt", "one\nthree\n"); + let first = fx.commit_all("first"); + fx.write("b.txt", "one\nTWO\nthree\n"); + fx.commit_all("second"); + fx.git(&["checkout", "-q", "-b", "feature", &first]); + fx.write("b.txt", "one\nfeature\nthree\n"); + let feature = fx.commit_all("feature line"); + fx.git(&["checkout", "-q", "main"]); + let repo = fx.repo(); + + // Blaming the feature content against HEAD marks its extra line uncommitted… + let text = "one\nfeature\nthree\n"; + let lines = repo.blame(&fx.path("b.txt"), Some(text), Some("HEAD"), encoding_rs::UTF_8).unwrap(); + assert_eq!(lines.len(), 3); + assert_eq!(lines[0].commit, first); + assert_eq!(lines[1].author, "Not Committed Yet"); + assert_eq!(lines[2].commit, first); + // …while blaming it against the feature commit resolves its real history. + let lines = repo.blame(&fx.path("b.txt"), Some(text), Some(&feature), encoding_rs::UTF_8).unwrap(); + assert_eq!(lines[1].commit, feature); + assert_eq!(lines[1].summary, "feature line"); + assert!(repo.blame(&fx.path("b.txt"), Some(text), Some("no-such-ref"), encoding_rs::UTF_8).is_err()); +} + #[test] fn log_filters_pagination_and_refs() { let fx = Fixture::new(); @@ -1379,7 +1408,7 @@ fn encoded_java_hunks_staging_blame_and_partial_commit_preserve_bytes() { let repo = fx.repo(); assert_eq!(repo.file_at(&path, None, encoding).unwrap().as_deref(), Some(old.as_str())); assert!( - repo.blame(&path, Some(&old.replace("\r\n", "\n")), encoding) + repo.blame(&path, Some(&old.replace("\r\n", "\n")), None, encoding) .unwrap() .iter() .all(|line| line.author == "Test User") diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index ea3488efd..e5be59910 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -57,8 +57,9 @@ pub async fn vcs_blame( state: State<'_, AppState>, path: PathBuf, text: Option, + revision: Option, ) -> IdeResult> { - ide_host::ops::vcs::vcs_blame(&state.host, window.label(), path, text).await + ide_host::ops::vcs::vcs_blame(&state.host, window.label(), path, text, revision).await } #[tauri::command] diff --git a/src/bindings.ts b/src/bindings.ts index 891b7efc2..32d839fe8 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -413,7 +413,7 @@ export const commands = { /** Gutter change markers for a file, computed against its current (unsaved) text. */ vcsLineHunks: (path: string) => typedError(__TAURI_INVOKE("vcs_line_hunks", { path })), vcsFileAt: (path: string, rev: string | null) => typedError(__TAURI_INVOKE("vcs_file_at", { path, rev })), - vcsBlame: (path: string, text: string | null) => typedError(__TAURI_INVOKE("vcs_blame", { path, text })), + vcsBlame: (path: string, text: string | null, revision: string | null) => typedError(__TAURI_INVOKE("vcs_blame", { path, text, revision })), vcsLog: (repo: string | null, query: LogQuery) => typedError(__TAURI_INVOKE("vcs_log", { repo, query })), vcsCommitFiles: (repo: string | null, commit: string) => typedError(__TAURI_INVOKE("vcs_commit_files", { repo, commit })), /** Combined changes of several commits, `commits` oldest first. */ diff --git a/src/editor/lsp/authorLens.ts b/src/editor/lsp/authorLens.ts index 5b8c1bbb3..ae4596786 100644 --- a/src/editor/lsp/authorLens.ts +++ b/src/editor/lsp/authorLens.ts @@ -76,7 +76,7 @@ export function blameFor(path: string, text: Text): Promise const hit = cache.get(path); if (hit && (hit.text === text || hit.text === null)) return hit.lines; const entry: Entry = { text, lines: Promise.resolve(null) }; - entry.lines = call(commands.vcsBlame(path, null)).then( + entry.lines = call(commands.vcsBlame(path, null, null)).then( (lines) => lines, () => { entry.text = null; diff --git a/src/features/vcs/MergeEditor.tsx b/src/features/vcs/MergeEditor.tsx index 71fe9b82c..4c899b98f 100644 --- a/src/features/vcs/MergeEditor.tsx +++ b/src/features/vcs/MergeEditor.tsx @@ -315,6 +315,9 @@ export function MergeEditor({ input }: { input: EditorInput }) { // IDEA's merge view: F7 / Shift+F7 jump between conflicts; each side pane can // annotate its version with Git Blame from the context menu. + // Each side pane blames on the branch it belongs to, so lines that only exist on + // the other branch show their real history instead of "Not Committed Yet". + const otherRev = state?.sides.commit ?? "HEAD"; const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "F7") { e.preventDefault(); @@ -322,7 +325,7 @@ export function MergeEditor({ input }: { input: EditorInput }) { } }; - const sideMenu = (e: React.MouseEvent, viewRef: { current: EditorView | null }, text: string) => { + const sideMenu = (e: React.MouseEvent, viewRef: { current: EditorView | null }, text: string, revision?: string) => { const view = viewRef.current; if (!view) return; e.preventDefault(); @@ -330,7 +333,7 @@ export function MergeEditor({ input }: { input: EditorInput }) { { label: "Annotate with Git Blame", icon: "changelist", - run: () => void toggleAnnotations(view, path, text), + run: () => void toggleAnnotations(view, path, text, revision), }, ]); }; @@ -392,7 +395,7 @@ export function MergeEditor({ input }: { input: EditorInput }) { className={styles.pane} aria-label={tx("Your version")} role="region" - onContextMenu={(e) => sideMenu(e, oursView, state?.ours ?? "")} + onContextMenu={(e) => sideMenu(e, oursView, state?.ours ?? "", state?.sides.reversed ? otherRev : "HEAD")} />
sideMenu(e, theirsView, state?.theirs ?? "")} + onContextMenu={(e) => sideMenu(e, theirsView, state?.theirs ?? "", state?.sides.reversed ? "HEAD" : otherRev)} />
diff --git a/src/features/vcs/blame.ts b/src/features/vcs/blame.ts index 4060e4834..d7f2839e7 100644 --- a/src/features/vcs/blame.ts +++ b/src/features/vcs/blame.ts @@ -106,7 +106,7 @@ class BlameRefresher implements PluginValue { private schedule(delay: number) { clearTimeout(this.timer); this.timer = setTimeout(() => { - void call(commands.vcsBlame(this.path, null)) + void call(commands.vcsBlame(this.path, null, null)) .then((lines) => { if (!this.destroyed && this.view.state.field(blameField, false)) this.view.dispatch({ effects: setBlame.of(lines) }); @@ -207,14 +207,15 @@ function setAnnotations(view: EditorView, lines: BlameLine[] | null, path: strin view.dispatch({ effects: blameCompartment.reconfigure(lines ? blameExtension(lines, path, live) : []) }); } -/** Toggles annotations in `view`; `text` blames that content instead of the file on disk. */ -export async function toggleAnnotations(view: EditorView, path: string, text?: string) { +/** Toggles annotations in `view`; `text` blames that content instead of the file on + * disk, `revision` the history it is blamed on (default HEAD). */ +export async function toggleAnnotations(view: EditorView, path: string, text?: string, revision?: string) { if (annotationsShown(view)) { setAnnotations(view, null, path, false); return; } try { - const lines = await call(commands.vcsBlame(path, text ?? null)); + const lines = await call(commands.vcsBlame(path, text ?? null, revision ?? null)); setAnnotations(view, lines, path, text === undefined); } catch (e) { notifications().notify({