Consolidate multiple Git backend calls into a single bundle to reduce overhead when opening and auto-refreshing repositories. This significantly improves performance by fetching status, branches, commits, and files in one optimized operation, instead of re-running 'git rev-parse' and 'git status' multiple times. Also, streamline commit history loading by fetching file changes inline via 'git log --name-status -z', eliminating expensive per-commit 'git diff-tree' processes. Additionally, introduce the ability to create new branches from a specific commit in the history and refactor the commit comparison feature into a dedicated dialog. The status panel now displays concise file names.
241 lines
8.4 KiB
Svelte
241 lines
8.4 KiB
Svelte
<script lang="ts">
|
|
import { ChevronDown, ChevronRight, GitBranch, RotateCcw } from "@lucide/svelte";
|
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
|
|
|
interface GraphSegment {
|
|
fromCol: number;
|
|
toCol: number;
|
|
color: string;
|
|
}
|
|
|
|
interface GraphRow {
|
|
dotCol: number;
|
|
dotColor: string;
|
|
top: GraphSegment[];
|
|
bottom: GraphSegment[];
|
|
}
|
|
|
|
const GRAPH_COLORS = [
|
|
"#2f6fb0", "#4aa777", "#c9851f", "#a05bd0",
|
|
"#cc4b6e", "#1f9ab0", "#7a8a1f", "#b0631f",
|
|
];
|
|
const GRAPH_LANE = 16;
|
|
|
|
interface Props {
|
|
commits: GitCommit[];
|
|
hasRepository: boolean;
|
|
isBusy: boolean;
|
|
expandedCommitHashes: Set<string>;
|
|
onRestoreCommit: (commit: GitCommit) => void;
|
|
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
|
onToggleCommitFiles: (hash: string) => void;
|
|
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
|
}
|
|
|
|
let {
|
|
commits = [],
|
|
hasRepository = false,
|
|
isBusy = false,
|
|
expandedCommitHashes = new Set(),
|
|
onRestoreCommit = () => {},
|
|
onPreviewCommitFile = () => {},
|
|
onToggleCommitFiles = () => {},
|
|
onCreateBranchFromCommit = () => {},
|
|
}: Props = $props();
|
|
|
|
function laneColor(col: number): string {
|
|
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
|
}
|
|
|
|
function graphColX(col: number): number {
|
|
return col * GRAPH_LANE + GRAPH_LANE / 2;
|
|
}
|
|
|
|
function computeGraph(items: GitCommit[]): { rows: GraphRow[]; columns: number } {
|
|
const rows: GraphRow[] = [];
|
|
let lanes: (string | null)[] = [];
|
|
let maxColumns = 1;
|
|
|
|
for (const commit of items) {
|
|
const before = lanes.slice();
|
|
|
|
let col = before.indexOf(commit.hash);
|
|
if (col === -1) {
|
|
col = before.indexOf(null);
|
|
if (col === -1) col = before.length;
|
|
}
|
|
|
|
const after = before.slice();
|
|
while (after.length <= col) after.push(null);
|
|
|
|
for (let k = 0; k < after.length; k++) {
|
|
if (after[k] === commit.hash) after[k] = null;
|
|
}
|
|
|
|
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
|
|
|
|
const fromCommit = new Set<number>([col]);
|
|
for (let p = 1; p < commit.parents.length; p++) {
|
|
let slot = after.indexOf(null);
|
|
if (slot === -1) { slot = after.length; after.push(null); }
|
|
after[slot] = commit.parents[p];
|
|
fromCommit.add(slot);
|
|
}
|
|
|
|
const top: GraphSegment[] = [];
|
|
for (let k = 0; k < before.length; k++) {
|
|
const target = before[k];
|
|
if (target == null) continue;
|
|
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k) });
|
|
}
|
|
|
|
const bottom: GraphSegment[] = [];
|
|
for (let k = 0; k < after.length; k++) {
|
|
if (after[k] == null) continue;
|
|
bottom.push({ fromCol: fromCommit.has(k) ? col : k, toCol: k, color: laneColor(k) });
|
|
}
|
|
|
|
rows.push({ dotCol: col, dotColor: laneColor(col), top, bottom });
|
|
|
|
lanes = after.slice();
|
|
while (lanes.length > 0 && lanes[lanes.length - 1] == null) lanes.pop();
|
|
maxColumns = Math.max(maxColumns, before.length, after.length, col + 1);
|
|
}
|
|
|
|
return { rows, columns: maxColumns };
|
|
}
|
|
|
|
function statusLabel(kind: FileStatusKind): string {
|
|
return kind;
|
|
}
|
|
|
|
function displayCommitFile(file: GitCommitFile): string {
|
|
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
|
}
|
|
|
|
function formatCommitDate(value: string): string {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
|
}
|
|
|
|
let graph = $derived(computeGraph(commits));
|
|
let graphRows = $derived(graph.rows);
|
|
let graphWidth = $derived(Math.max(graph.columns, 1) * GRAPH_LANE);
|
|
</script>
|
|
|
|
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
|
|
<div class="section-head">
|
|
<div>
|
|
<span class="eyebrow">History</span>
|
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
|
</div>
|
|
<span class="pill pill-count">{commits.length}</span>
|
|
</div>
|
|
|
|
{#if !hasRepository}
|
|
<div class="blank-state">No repository loaded.</div>
|
|
{:else if commits.length === 0}
|
|
<div class="blank-state">No commits returned.</div>
|
|
{:else}
|
|
<div class="history-list graph-list overflow-auto">
|
|
{#each commits as item, rowIndex (item.hash)}
|
|
{@const row = graphRows[rowIndex]}
|
|
<article class="commit-row graph-row">
|
|
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
|
|
{#if row}
|
|
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
|
|
{#each row.top as seg}
|
|
<line
|
|
x1={graphColX(seg.fromCol)} y1="0"
|
|
x2={graphColX(seg.toCol)} y2="50"
|
|
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke"
|
|
/>
|
|
{/each}
|
|
{#each row.bottom as seg}
|
|
<line
|
|
x1={graphColX(seg.fromCol)} y1="50"
|
|
x2={graphColX(seg.toCol)} y2="100"
|
|
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke"
|
|
/>
|
|
{/each}
|
|
</svg>
|
|
<span
|
|
class="graph-dot"
|
|
class:merge={item.parents.length > 1}
|
|
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
|
></span>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="commit-body">
|
|
<div class="commit-line">
|
|
<div>
|
|
<strong title={item.summary}>{item.summary}</strong>
|
|
<span>{item.short_hash} - {item.author_name}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{#if item.refs.length > 0}
|
|
<div class="ref-list" aria-label="Commit refs">
|
|
{#each item.refs as ref}
|
|
<span>{ref}</span>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if item.files.length > 0}
|
|
<div class="commit-files">
|
|
<button
|
|
class="commit-files-toggle"
|
|
type="button"
|
|
onclick={() => onToggleCommitFiles(item.hash)}
|
|
aria-expanded={expandedCommitHashes.has(item.hash)}
|
|
>
|
|
{#if expandedCommitHashes.has(item.hash)}
|
|
<ChevronDown size={14} aria-hidden="true" />
|
|
{:else}
|
|
<ChevronRight size={14} aria-hidden="true" />
|
|
{/if}
|
|
{item.files.length} {item.files.length === 1 ? "file" : "files"} changed
|
|
</button>
|
|
|
|
{#if expandedCommitHashes.has(item.hash)}
|
|
<div class="commit-file-list" aria-label="Changed files">
|
|
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
|
|
<button
|
|
class="commit-file-button"
|
|
type="button"
|
|
onclick={() => onPreviewCommitFile(item, file)}
|
|
disabled={isBusy}
|
|
title="Show differences before restoring"
|
|
>
|
|
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
|
|
<strong>{displayCommitFile(file)}</strong>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="commit-actions">
|
|
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
|
<div class="commit-action-buttons">
|
|
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit">
|
|
<GitBranch size={15} aria-hidden="true" />
|
|
Branch
|
|
</button>
|
|
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Reset current branch to this commit">
|
|
<RotateCcw size={15} aria-hidden="true" />
|
|
Restore
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|