From d4ac449074d2230cbb314a3aad66c6ad5504ae67 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 7 Jul 2026 15:09:25 +0200 Subject: [PATCH] feat(blame): add file blame dialog with git porcelain parsing This change introduces a new backend command to fetch file blame using git's line-porcelain output and returns structured per-line metadata. The UI adds a BlameDialog that groups lines by commit, highlights uncommitted changes, and styles the dialog to match the updated theme. - Add get_file_blame command and parsing with uncommitted detection - Wire BlameDialog into the explorer file node actions - Add blame UI component and supporting types and styles --- .claude/settings.local.json | 9 +- src-tauri/src/git.rs | 160 +++++++++++++++++ src-tauri/src/main.rs | 5 +- src/App.svelte | 47 +++++ src/app.css | 172 ++++++++++++++++--- src/lib/components/BlameDialog.svelte | 135 +++++++++++++++ src/lib/components/ExplorerPanel.svelte | 20 +++ src/lib/components/RepoLoadingOverlay.svelte | 3 +- src/lib/git.ts | 5 + src/lib/types.ts | 17 ++ 10 files changed, 548 insertions(+), 25 deletions(-) create mode 100644 src/lib/components/BlameDialog.svelte diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4520a74..909b362 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -85,7 +85,14 @@ "Bash(rustc --edition 2021 --crate-type bin -o /dev/null --emit=metadata src/main.rs)", "Bash(grep -B1 \"^error\\\\[E0432\\\\]\\\\|^error$\")", "Bash(grep -v \"^--$\")", - "Bash(grep \"^error$\" -A2)" + "Bash(grep \"^error$\" -A2)", + "Bash(cargo run *)", + "Bash(node_modules/.bin/svelte-check --version)", + "Bash(rustfmt --check --edition 2021 src/git.rs src/main.rs)", + "Bash(rustfmt --check --edition 2021 src/git.rs)", + "Bash(awk *)", + "Bash(rustfmt --edition 2021 /tmp/blame_chunk.rs)", + "Read(//tmp/**)" ] } } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index e0a376e..172f188 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -117,6 +117,25 @@ pub struct GitCommitComparison { pub patch: String, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitBlameLine { + pub line_number: u32, + pub content: String, + pub commit_hash: String, + pub short_hash: String, + pub author_name: String, + pub author_email: String, + pub author_time: i64, + pub summary: String, + pub is_uncommitted: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitBlameResult { + pub path: String, + pub lines: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct ConflictFile { pub path: String, @@ -1607,6 +1626,107 @@ pub fn cancel_file_history( state.cancel(request_id) } +const UNCOMMITTED_BLAME_HASH: &str = "0000000000000000000000000000000000000000"; + +#[tauri::command] +pub fn get_file_blame(path: String, file: String) -> Result { + let repo = resolve_repo(&path)?; + validate_files(std::slice::from_ref(&file))?; + + if verify_commit(&repo, "HEAD").is_err() { + return Err("Repository has no commits yet.".to_string()); + } + + let args = vec![ + OsString::from("blame"), + OsString::from("--line-porcelain"), + OsString::from("--"), + OsString::from(file.clone()), + ]; + let output = + run_git(&repo, args).map_err(|err| format!("Could not load blame for '{file}': {err}"))?; + + Ok(GitBlameResult { + path: file, + lines: parse_blame_porcelain(&output), + }) +} + +fn parse_blame_porcelain(output: &[u8]) -> Vec { + #[derive(Default, Clone)] + struct BlameMeta { + author_name: String, + author_email: String, + author_time: i64, + summary: String, + } + + let text = String::from_utf8_lossy(output); + let mut lines_out: Vec = Vec::new(); + let mut commit_meta: BTreeMap = BTreeMap::new(); + let mut current_hash = String::new(); + let mut current_final_line: u32 = 0; + + for line in text.split('\n') { + if let Some(content) = line.strip_prefix('\t') { + let meta = commit_meta.get(¤t_hash).cloned().unwrap_or_default(); + lines_out.push(GitBlameLine { + line_number: current_final_line, + content: content.to_string(), + commit_hash: current_hash.clone(), + short_hash: short_hash(¤t_hash), + author_name: meta.author_name, + author_email: meta.author_email, + author_time: meta.author_time, + summary: meta.summary, + is_uncommitted: current_hash == UNCOMMITTED_BLAME_HASH, + }); + continue; + } + + let mut parts = line.splitn(2, ' '); + let head = parts.next().unwrap_or(""); + let tail = parts.next().unwrap_or(""); + + if head.len() == 40 && head.bytes().all(|b| b.is_ascii_hexdigit()) { + if let Some(final_line) = tail.split_whitespace().nth(1) { + current_final_line = final_line.parse().unwrap_or(current_final_line); + } + current_hash = head.to_string(); + commit_meta.entry(current_hash.clone()).or_default(); + continue; + } + + match head { + "author" => { + commit_meta + .entry(current_hash.clone()) + .or_default() + .author_name = tail.to_string() + } + "author-mail" => { + let email = tail.trim_matches(|c| c == '<' || c == '>').to_string(); + commit_meta + .entry(current_hash.clone()) + .or_default() + .author_email = email; + } + "author-time" => { + commit_meta + .entry(current_hash.clone()) + .or_default() + .author_time = tail.parse().unwrap_or(0); + } + "summary" => { + commit_meta.entry(current_hash.clone()).or_default().summary = tail.to_string() + } + _ => {} + } + } + + lines_out +} + fn list_file_history_core( repo: &Path, file: String, @@ -5419,4 +5539,44 @@ mod tests { assert_eq!(result.unwrap_err(), SEARCH_CANCELLED_MESSAGE); } + + #[test] + fn get_file_blame_attributes_lines_to_the_commits_that_introduced_them() { + let repo = init_temp_repo("file_blame"); + commit_initial_file(&repo.path); + fs::write(repo.path.join("old.txt"), "original\ntwo\n").expect("tracked file should change"); + run_git_test(&repo.path, ["add", "old.txt"]); + run_git_test(&repo.path, ["commit", "-q", "-m", "add second line"]); + + let result = get_file_blame( + repo.path.to_string_lossy().to_string(), + "old.txt".to_string(), + ) + .unwrap(); + + assert_eq!(result.path, "old.txt"); + assert_eq!(result.lines.len(), 2); + assert_eq!(result.lines[0].content, "original"); + assert_eq!(result.lines[0].summary, "init"); + assert_eq!(result.lines[1].content, "two"); + assert_eq!(result.lines[1].summary, "add second line"); + assert!(!result.lines[1].author_name.is_empty()); + assert!(!result.lines[1].is_uncommitted); + } + + #[test] + fn get_file_blame_marks_uncommitted_working_tree_changes() { + let repo = init_temp_repo("file_blame_uncommitted"); + commit_initial_file(&repo.path); + fs::write(repo.path.join("old.txt"), "changed\n").expect("tracked file should change"); + + let result = get_file_blame( + repo.path.to_string_lossy().to_string(), + "old.txt".to_string(), + ) + .unwrap(); + + assert_eq!(result.lines.len(), 1); + assert!(result.lines[0].is_uncommitted); + } } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5c77c94..55bbf8d 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -10,8 +10,8 @@ use git::{ cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent, create_branch, create_tag, cred_delete, cred_load, cred_save, - delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_patch, - get_remote_url, get_status, last_commit_message, list_branches, list_commits, + delete_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame, + get_file_patch, get_remote_url, get_status, last_commit_message, list_branches, list_commits, list_file_history, list_repository_files, list_stashes, list_tags, merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, rename_branch, @@ -76,6 +76,7 @@ fn main() { open_repository_bundle, list_file_history, cancel_file_history, + get_file_blame, compare_commits, compare_file_to_head, compare_file_to_parent, diff --git a/src/App.svelte b/src/App.svelte index ce13e70..34f73ca 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -7,6 +7,7 @@ import TitleBar from "./lib/TitleBar.svelte"; import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; + import BlameDialog from "./lib/components/BlameDialog.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte"; import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte"; @@ -50,6 +51,7 @@ diffFileAgainstWorkingTree, compareFileToParent, fetchRemote, + getFileBlame, getStatus, lastCommitMessage, listBranches, @@ -97,6 +99,7 @@ ConflictFile, ExplorerNode, ExplorerNodeKind, + GitBlameLine, GitBranch as GitBranchInfo, GitCommit, GitCommitFile, @@ -218,6 +221,11 @@ let linePatchText = ""; let linePatchLoading = false; let linePatchError = ""; + let blameOpen = false; + let blameFilePath = ""; + let blameLines: GitBlameLine[] = []; + let blameLoading = false; + let blameError = ""; let pendingDiscard: PendingDiscard | null = null; let globalSearchOpen = false; let lastSearchQuery = ""; @@ -1921,6 +1929,33 @@ linePatchError = ""; } + async function openBlame(node: ExplorerNode) { + if (!activeRepoPath || node.kind !== "file") return; + blameOpen = true; + blameFilePath = node.path; + blameLines = []; + blameError = ""; + blameLoading = true; + + try { + const result = await getFileBlame(activeRepoPath, node.path); + blameLines = result.lines; + } catch (error) { + blameError = errorToMessage(error); + errorMessage = blameError; + } finally { + blameLoading = false; + } + } + + function closeBlame() { + if (isBusy) return; + blameOpen = false; + blameFilePath = ""; + blameLines = []; + blameError = ""; + } + function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string { switch (action) { case "stage": @@ -2726,6 +2761,7 @@ onCollapseAllFolders={collapseAllExplorerFolders} onSelectNode={selectExplorerNode} onOpenFile={openFileFromExplorer} + onBlame={openBlame} /> @@ -2886,6 +2922,17 @@ /> {/if} +{#if blameOpen} + +{/if} + {#if pendingDiscard} div:first-child { min-width: 0; } .dialog-header-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex: 0 0 auto; min-width: 0; } .compare-restore { max-width: 170px; min-width: 0; } @@ -2657,7 +2662,7 @@ .dialog-body { display: grid; grid-template-columns: 280px minmax(0, 1fr); min-height: 0; } .compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); } - .dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); } + .dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: #171a2b; } .dialog-file-row { display: grid; @@ -2687,7 +2692,7 @@ gap: 8px; padding: 7px 12px; border-bottom: 1px solid var(--color-border-subtle); - background: var(--color-surface-dim); + background: #171a2b; font-family: var(--font-mono); font-size: 12px; color: var(--color-ink-muted); @@ -2709,7 +2714,7 @@ font-size: 12px; line-height: 1.5; tab-size: 2; - background: var(--color-surface-raised); + background: #111321; } .split-pane { @@ -2733,7 +2738,7 @@ white-space: pre-wrap; word-break: break-all; } - .split-span.split-meta { color: var(--color-ink-faint); background: rgba(0,0,0,0.18); font-size: 11px; } + .split-span.split-meta { color: var(--color-ink-faint); background: #0c0e18; font-size: 11px; } .split-span.split-hunk { color: #7aacff; background: rgba(122,172,255,0.08); padding: 3px 10px; } .split-num { @@ -2743,11 +2748,11 @@ font-size: 11px; user-select: none; border-right: 1px solid var(--color-border-subtle); - background: rgba(0,0,0,0.14); + background: #0d101a; } .split-num.del { background: rgba(232,96,96,0.12); color: rgba(232,96,96,0.6); border-right-color: rgba(232,96,96,0.2); } .split-num.add { background: rgba(78,202,118,0.1); color: rgba(78,202,118,0.6); border-right-color: rgba(78,202,118,0.2); } - .split-num.empty { background: rgba(0,0,0,0.08); } + .split-num.empty { background: #10131e; } .split-cell { padding: 0 8px; @@ -2758,7 +2763,7 @@ } .split-cell.del { background: rgba(232,96,96,0.1); color: #ef8080; } .split-cell.add { background: rgba(78,202,118,0.09); color: #5dd88a; } - .split-cell.empty { background: rgba(0,0,0,0.06); } + .split-cell.empty { background: #10131e; } /* Search-hit highlight: amber, distinct from add (green) / del (red). Higher specificity so it overrides the add/del backgrounds on a matched line. */ @@ -2789,7 +2794,7 @@ text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-ink-faint); - background: rgba(0,0,0,0.1); + background: #0d101a; } .split-col-label + .split-col-label { border-left: 1px solid var(--color-border-subtle); } .split-col-hash { @@ -2805,7 +2810,7 @@ letter-spacing: 0; } - .dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); } + .dialog-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--color-border-subtle); background: #171a2b; } .dialog-footer-info { color: var(--color-ink-dim); font-size: 13px; font-weight: 700; } .prepared-tag { display: inline-flex; align-items: center; gap: 4px; margin-right: auto; color: #4eca76; font-size: 12px; font-weight: 700; } @@ -2980,6 +2985,134 @@ font-family: var(--font-mono); } + .blame-body { + min-height: 0; + overflow: hidden; + background: #111321; + } + + .blame-code-header strong { + flex: 0 0 auto; + margin-left: auto; + color: var(--color-ink-faint); + font-size: 11px; + font-weight: 800; + } + + .blame-column-headers { + grid-template-columns: minmax(300px, 340px) minmax(0, 1fr); + } + .blame-commit-col-label { + padding-left: 12px; + } + .blame-code-col-label { + padding-left: calc(3.2rem + 10px); + } + .blame-commit-col-label + .blame-code-col-label { + border-left: 1px solid var(--color-border-subtle); + } + + .blame-diff { + grid-template-columns: minmax(0, 1fr); + } + + .blame-scroll { + min-height: 0; + overflow: auto; + } + + .blame-code-table { + width: max-content; + min-width: 100%; + } + + .blame-group { + display: grid; + grid-template-columns: minmax(300px, 340px) minmax(max-content, 1fr); + align-items: stretch; + min-width: max-content; + border-bottom: 1px solid var(--color-border-subtle); + } + .blame-group.uncommitted { + background: #171725; + } + + .blame-meta { + position: sticky; + left: 0; + z-index: 1; + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + padding: 8px 12px; + border-right: 1px solid var(--color-border-subtle); + background: #0d101a; + box-shadow: 8px 0 18px rgba(0, 0, 0, 0.18); + } + .blame-hash { + align-self: flex-start; + overflow: hidden; + max-width: 100%; + padding: 2px 7px; + border: 1px solid rgba(90,140,248,0.2); + border-radius: 5px; + background: #141b2d; + color: var(--color-accent); + font-size: 10.5px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; + } + .blame-author { + overflow: hidden; + color: var(--color-ink); + font-size: 12px; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; + } + .blame-summary { + overflow: hidden; + color: #aeb6d8; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; + } + .blame-date { + color: var(--color-ink-faint); + font-size: 10.5px; + } + .blame-group:hover .blame-meta { + background: #111728; + } + .blame-group.uncommitted .blame-hash, + .blame-group.uncommitted .blame-author { + color: #e8b45a; + } + .blame-group.uncommitted .blame-hash { + border-color: rgba(232, 180, 90, 0.26); + background: #271f14; + } + + .blame-lines { + min-width: max-content; + grid-template-columns: 4rem minmax(max-content, 1fr); + } + .blame-line-number { + min-height: 20px; + padding-right: 8px; + } + .blame-line-code { + min-height: 20px; + } + .blame-group:hover .blame-line-number { + background: #111728; + } + .blame-group:hover .blame-line-code { + background: #151a2b; + } + .global-search-body { display: grid; grid-template-rows: auto auto minmax(0, 1fr); @@ -3370,11 +3503,10 @@ border: 1px solid rgba(100, 108, 255, 0.36); border-radius: 14px; background: - linear-gradient(180deg, rgba(255,255,255,0.06), transparent 42%), - rgba(15, 16, 28, 0.96); + linear-gradient(180deg, #222743 0%, #111321 42%), + #111321; box-shadow: 0 32px 90px rgba(0, 0, 0, 0.68), 0 0 0 1px rgba(255,255,255,0.04) inset; overflow: hidden; - backdrop-filter: blur(18px); } .cred-hero { diff --git a/src/lib/components/BlameDialog.svelte b/src/lib/components/BlameDialog.svelte new file mode 100644 index 0000000..b9a7884 --- /dev/null +++ b/src/lib/components/BlameDialog.svelte @@ -0,0 +1,135 @@ + + + diff --git a/src/lib/components/ExplorerPanel.svelte b/src/lib/components/ExplorerPanel.svelte index 66db7f9..5c6f9bc 100644 --- a/src/lib/components/ExplorerPanel.svelte +++ b/src/lib/components/ExplorerPanel.svelte @@ -18,6 +18,7 @@ Folder, FolderOpen, ExternalLink, + History, Terminal, } from "@lucide/svelte"; import { languageIconForPath } from "../languageIcons"; @@ -36,6 +37,7 @@ onCollapseAllFolders: () => void; onSelectNode: (node: ExplorerNode) => void; onOpenFile: (node: ExplorerNode) => void; + onBlame: (node: ExplorerNode) => void; } let { @@ -50,6 +52,7 @@ onCollapseAllFolders = () => {}, onSelectNode = () => {}, onOpenFile = () => {}, + onBlame = () => {}, }: Props = $props(); let contextNode = $state(null); @@ -182,6 +185,13 @@ onOpenFile(node); } + function openContextBlame() { + const node = contextNode; + if (!node || node.kind !== "file") return; + closeFileContextMenu(); + onBlame(node); + } + function handleWindowKeydown(event: KeyboardEvent) { if (event.key === "Escape") closeFileContextMenu(); } @@ -335,5 +345,15 @@