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:
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user