дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
1 джерело
f87d153caf
коміт
67e3c60db8
11 files changed
+75
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
///
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Заблокувати користувача