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 <[email protected]>
Цей коміт міститься в:
arookieofcтаa-rookie-of-C-language authored and GitHub committed 2026-10-30 19:42:05 +08:00
1 джерело f87d153caf
коміт 67e3c60db8
11 files changed
+75 -23

No files matched your search

+13 -3
Переглянути файл
@@ -201,12 +201,20 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
.await
}
pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf, text: Option<String>) -> IdeResult<Vec<BlameLine>> {
pub async fn vcs_blame(
host: &Host,
window: &str,
path: PathBuf,
text: Option<String>,
revision: Option<String>,
) -> IdeResult<Vec<BlameLine>> {
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
+1 -1
Переглянути файл
@@ -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();
+4 -1
Переглянути файл
@@ -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<Vec<BlameLine>> {
write::blame(self, path, current_text, encoding)
write::blame(self, path, current_text, revision, encoding)
}
/// Commits newest-first in topological order (children before parents).
///
+7 -2
Переглянути файл
@@ -47,9 +47,14 @@ pub(crate) fn blame(
r: &Repo,
path: &Path,
current_text: Option<&str>,
revision: Option<&str>,
encoding: &'static Encoding,
) -> IdeResult<Vec<BlameLine>> {
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());
+1 -1
Переглянути файл
@@ -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);
+33 -4
Переглянути файл
@@ -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")
+2 -1
Переглянути файл
@@ -57,8 +57,9 @@ pub async fn vcs_blame(
state: State<'_, AppState>,
path: PathBuf,
text: Option<String>,
revision: Option<String>,
) -> IdeResult<Vec<BlameLine>> {
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]
+1 -1
Переглянути файл
@@ -413,7 +413,7 @@ export const commands = {
/** Gutter change markers for a file, computed against its current (unsaved) text. */
vcsLineHunks: (path: string) => typedError<LineHunk[], IdeError>(__TAURI_INVOKE("vcs_line_hunks", { path })),
vcsFileAt: (path: string, rev: string | null) => typedError<string | null, IdeError>(__TAURI_INVOKE("vcs_file_at", { path, rev })),
vcsBlame: (path: string, text: string | null) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path, text })),
vcsBlame: (path: string, text: string | null, revision: string | null) => typedError<BlameLine[], IdeError>(__TAURI_INVOKE("vcs_blame", { path, text, revision })),
vcsLog: (repo: string | null, query: LogQuery) => typedError<Commit[], IdeError>(__TAURI_INVOKE("vcs_log", { repo, query })),
vcsCommitFiles: (repo: string | null, commit: string) => typedError<ChangedFile[], IdeError>(__TAURI_INVOKE("vcs_commit_files", { repo, commit })),
/** Combined changes of several commits, `commits` oldest first. */
+1 -1
Переглянути файл
@@ -76,7 +76,7 @@ export function blameFor(path: string, text: Text): Promise<BlameLine[] | null>
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;
+7 -4
Переглянути файл
@@ -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")}
/>
<div ref={resultRef} className={styles.pane} aria-label={tx("Result")} role="region" />
<div
@@ -400,7 +403,7 @@ export function MergeEditor({ input }: { input: EditorInput }) {
className={styles.pane}
aria-label={tx("Their version")}
role="region"
onContextMenu={(e) => sideMenu(e, theirsView, state?.theirs ?? "")}
onContextMenu={(e) => sideMenu(e, theirsView, state?.theirs ?? "", state?.sides.reversed ? "HEAD" : otherRev)}
/>
</div>
</div>
+5 -4
Переглянути файл
@@ -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({