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
This commit is contained in:
Christoph Brandau
2026-07-07 15:09:25 +02:00
parent a67663f3c5
commit d4ac449074
10 changed files with 548 additions and 25 deletions
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import { FileCode, LoaderCircle, X } from "@lucide/svelte";
import type { GitBlameLine } from "../types";
interface BlameGroup {
id: string;
hash: string;
shortHash: string;
authorName: string;
authorEmail: string;
summary: string;
authorTime: number;
isUncommitted: boolean;
lines: GitBlameLine[];
}
interface Props {
filePath: string;
lines: GitBlameLine[];
isBusy: boolean;
isLoading: boolean;
error: string;
onClose: () => void;
}
let {
filePath = "",
lines = [],
isBusy = false,
isLoading = false,
error = "",
onClose = () => {},
}: Props = $props();
function groupBlameLines(source: GitBlameLine[]): BlameGroup[] {
const groups: BlameGroup[] = [];
for (const line of source) {
const last = groups[groups.length - 1];
if (last && last.hash === line.commit_hash) {
last.lines.push(line);
continue;
}
groups.push({
id: `${line.commit_hash}-${line.line_number}`,
hash: line.commit_hash,
shortHash: line.short_hash,
authorName: line.author_name,
authorEmail: line.author_email,
summary: line.summary,
authorTime: line.author_time,
isUncommitted: line.is_uncommitted,
lines: [line],
});
}
return groups;
}
function formatBlameDate(seconds: number): string {
if (!seconds) return "";
const date = new Date(seconds * 1000);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
}
function groupTooltip(group: BlameGroup): string {
if (group.isUncommitted) return "Not committed yet";
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
}
let groups = $derived(groupBlameLines(lines));
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label="File blame">
<header class="dialog-header">
<div>
<span class="eyebrow">Blame</span>
<p class="dialog-title" title={filePath}>{filePath}</p>
</div>
<div class="dialog-header-actions">
<span class="pill pill-count">{lines.length}</span>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div class="dialog-diff blame-body">
{#if isLoading}
<div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading blame...
</div>
{:else if error}
<div class="blank-state">{error}</div>
{:else if groups.length === 0}
<div class="blank-state">No blame information available for this file.</div>
{:else}
<div class="diff-header blame-code-header">
<FileCode size={13} aria-hidden="true" />
<span title={filePath}>{filePath}</span>
<strong>{lines.length} lines</strong>
</div>
<div class="split-col-headers blame-column-headers">
<div class="split-col-label blame-commit-col-label">Commit</div>
<div class="split-col-label blame-code-col-label">Code</div>
</div>
<div class="split-diff blame-diff" role="table" aria-label="File blame">
<div class="split-pane blame-scroll">
<div class="blame-code-table">
{#each groups as group (group.id)}
<div class="blame-group" class:uncommitted={group.isUncommitted} title={groupTooltip(group)}>
<div class="blame-meta">
<span class="blame-hash">{group.isUncommitted ? "Uncommitted" : group.shortHash}</span>
<span class="blame-author">{group.isUncommitted ? "Not committed yet" : group.authorName}</span>
<span class="blame-summary">{group.summary || "No commit message"}</span>
{#if !group.isUncommitted}
<span class="blame-date">{formatBlameDate(group.authorTime)}</span>
{/if}
</div>
<div class="split-pane-grid blame-lines">
{#each group.lines as line (line.line_number)}
<span class="split-num blame-line-number">{line.line_number}</span>
<code class="split-cell blame-line-code">{line.content}</code>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
+20
View File
@@ -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<ExplorerNode | null>(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 @@
<ExternalLink size={14} aria-hidden="true" />
Open in Explorer
</button>
<button
type="button"
role="menuitem"
onclick={openContextBlame}
disabled={!contextNode.tracked || contextNode.status === "deleted"}
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
>
<History size={14} aria-hidden="true" />
Blame
</button>
</div>
{/if}
+1 -2
View File
@@ -47,8 +47,7 @@
z-index: 400;
display: grid;
place-items: center;
background: rgba(6, 6, 14, 0.72);
backdrop-filter: blur(8px);
background: #050712;
animation: overlay-in 180ms ease;
}
+5
View File
@@ -5,6 +5,7 @@ import type {
CommitAiProvider,
CommitAiStatus,
ConflictFile,
GitBlameResult,
GitBranch,
GitCommit,
GitCommitComparison,
@@ -322,6 +323,10 @@ export function cancelFileHistory(requestId: string): Promise<void> {
return invoke<void>("cancel_file_history", { requestId });
}
export function getFileBlame(path: string, file: string): Promise<GitBlameResult> {
return invoke<GitBlameResult>("get_file_blame", { path, file });
}
export function compareCommits(
path: string,
from: string,
+17
View File
@@ -175,6 +175,23 @@ export interface ConflictFile {
theirs_size: number | null;
}
export interface GitBlameLine {
line_number: number;
content: string;
commit_hash: string;
short_hash: string;
author_name: string;
author_email: string;
author_time: number;
summary: string;
is_uncommitted: boolean;
}
export interface GitBlameResult {
path: string;
lines: GitBlameLine[];
}
export interface StoredCredential {
username: string;
password: string;