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
+47
View File
@@ -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}
/>
</aside>
@@ -2886,6 +2922,17 @@
/>
{/if}
{#if blameOpen}
<BlameDialog
filePath={blameFilePath}
lines={blameLines}
{isBusy}
isLoading={blameLoading}
error={blameError}
onClose={closeBlame}
/>
{/if}
{#if pendingDiscard}
<DiscardConfirmDialog
file={pendingDiscard.file}
+152 -20
View File
@@ -234,8 +234,7 @@
display: grid;
place-items: center;
padding: 24px;
background: rgba(4,8,18,0.58);
backdrop-filter: blur(8px);
background: #050712;
}
.branch-filter-dialog {
@@ -247,8 +246,8 @@
border: 1px solid rgba(94,110,156,0.24);
border-radius: 10px;
background:
linear-gradient(180deg, rgba(255,255,255,0.045), transparent 70%),
var(--color-surface);
linear-gradient(180deg, #20253f 0%, #151827 72%),
#151827;
box-shadow: 0 24px 80px rgba(0,0,0,0.42), inset 0 1px 0 rgba(255,255,255,0.05);
}
@@ -2416,8 +2415,8 @@
display: grid;
place-items: center;
padding: 24px;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(2px);
background-color: #050712;
background-image: none;
}
.dialog {
@@ -2427,7 +2426,8 @@
height: min(760px, 100%);
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface);
background-color: #111321;
background-image: none;
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.6), 0 2px 12px rgba(0,0,0,0.4);
overflow: hidden;
}
@@ -2444,6 +2444,11 @@
width: min(1320px, calc(100vw - 32px));
height: min(860px, calc(100vh - 32px));
}
.blame-dialog {
grid-template-rows: auto minmax(0, 1fr);
width: min(1320px, calc(100vw - 32px));
height: min(860px, calc(100vh - 32px));
}
.compare-select-dialog {
display: block;
width: min(720px, calc(100vw - 32px));
@@ -2643,7 +2648,7 @@
gap: 8px;
}
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: #171a2b; }
.dialog-header > 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 {
+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;