This commit is contained in:
Christoph Brandau
2026-06-27 17:42:58 +02:00
parent ef1974f31f
commit 25700821d1
6 changed files with 976 additions and 143 deletions
+563 -122
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import {
AlertCircle,
ArrowRight,
@@ -37,6 +39,7 @@
push,
readConflict,
resolveConflict,
resolveConflictSide,
restoreFileFromCommit,
restoreFiles,
restoreToCommit,
@@ -74,6 +77,35 @@
| { kind: "text"; lines: string[] }
| { kind: "conflict"; index: number; oursLines: string[]; theirsLines: string[] };
type PreparedResolution =
| { kind: "content"; content: string }
| { kind: "side"; side: "ours" | "theirs" };
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;
let repoPath = "";
let activeRepoPath = "";
let status: GitStatus | null = null;
@@ -83,6 +115,7 @@
let selectedExplorerPath = "";
let selectedExplorerKind: ExplorerNodeKind = "file";
let expandedExplorerPaths = new Set<string>();
let expandedCommitHashes = new Set<string>();
let fileHistory: GitCommit[] = [];
let commitMessage = "";
let errorMessage = "";
@@ -99,6 +132,13 @@
let conflictParts: ConflictPart[] = [];
let conflictChoices: (ConflictChoice | null)[] = [];
let manualMode = false;
let binarySide: "ours" | "theirs" | null = null;
let preparedResolutions: Record<string, PreparedResolution> = {};
let autoRefreshEnabled = true;
let autoRefreshInFlight = false;
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
$: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null;
@@ -124,6 +164,15 @@
? resolveContent
: buildResolution(conflictParts, conflictChoices);
$: resolveHasMarkers = /^<{7}/m.test(resolvedContent) || /^>{7}/m.test(resolvedContent);
$: preparedCount = Object.keys(preparedResolutions).length;
$: currentPrepared = conflictTarget.length > 0 && preparedResolutions[conflictTarget] != null;
$: canMarkResolved =
!!conflict && !isBusy && (conflict.binary ? binarySide != null : !resolveHasMarkers);
$: localBranches = branches.filter((branch) => !branch.remote);
$: remoteBranches = branches.filter((branch) => branch.remote);
$: graph = computeGraph(commits);
$: graphRows = graph.rows;
$: graphWidth = Math.max(graph.columns, 1) * GRAPH_LANE;
$: diffByPath = comparison ? buildDiffByPath(comparison.patch) : new Map<string, string>();
$: selectedDiffFile = comparison?.files.find((file) => file.path === selectedDiffPath) ?? null;
$: selectedDiffPatch = selectedDiffFile ? diffByPath.get(selectedDiffFile.path) ?? "" : "";
@@ -137,8 +186,71 @@
status = nextStatus;
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
repoPath = activeRepoPath;
lastStatusFingerprint = statusFingerprint(nextStatus);
}
function statusFingerprint(value: GitStatus): string {
return JSON.stringify({
branch: value.current_branch,
upstream: value.upstream,
ahead: value.ahead,
behind: value.behind,
files: value.files,
});
}
async function autoRefreshTick() {
if (
!autoRefreshEnabled ||
!activeRepoPath ||
isBusy ||
autoRefreshInFlight ||
resolveDialogOpen ||
compareDialogOpen
) {
return;
}
autoRefreshInFlight = true;
try {
const nextStatus = await getStatus(activeRepoPath);
// Cheap guard: only do the heavier refresh when something actually changed.
if (statusFingerprint(nextStatus) === lastStatusFingerprint) {
return;
}
applyStatus(nextStatus);
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
} catch {
// Ignore transient errors during background refresh (e.g. mid-operation).
} finally {
autoRefreshInFlight = false;
}
}
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) {
void autoRefreshTick();
}
}
onMount(() => {
autoRefreshTimer = setInterval(() => {
void autoRefreshTick();
}, AUTO_REFRESH_INTERVAL);
});
onDestroy(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
}
});
function buildExplorerTree(files: GitRepositoryFile[]): ExplorerNode[] {
const roots: ExplorerNode[] = [];
const folders = new Map<string, ExplorerNode>();
@@ -375,6 +487,7 @@
selectedExplorerPath = "";
selectedExplorerKind = "file";
expandedExplorerPaths = new Set<string>();
expandedCommitHashes = new Set<string>();
fileHistory = [];
compareFrom = "";
compareTo = "";
@@ -388,6 +501,8 @@
conflictParts = [];
conflictChoices = [];
manualMode = false;
binarySide = null;
preparedResolutions = {};
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
@@ -639,10 +754,29 @@
async function loadConflict(file: string) {
conflictTarget = file;
conflict = await readConflict(activeRepoPath, file);
const prepared = preparedResolutions[file];
if (conflict.binary) {
conflictParts = [];
conflictChoices = [];
manualMode = false;
resolveContent = "";
binarySide = prepared && prepared.kind === "side" ? prepared.side : null;
return;
}
binarySide = null;
conflictParts = parseConflicts(conflict.content);
conflictChoices = conflictParts.filter((part) => part.kind === "conflict").map(() => null);
manualMode = false;
resolveContent = conflict.content;
if (prepared && prepared.kind === "content") {
// Re-open an already prepared text resolution for review in manual mode.
resolveContent = prepared.content;
manualMode = true;
} else {
resolveContent = conflict.content;
manualMode = false;
}
}
function parseConflicts(content: string): ConflictPart[] {
@@ -765,6 +899,92 @@
manualMode = false;
}
function graphColX(column: number): number {
return column * GRAPH_LANE + GRAPH_LANE / 2;
}
function laneColor(column: number): string {
return GRAPH_COLORS[((column % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
}
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();
// Column for this commit: the first lane already waiting for it, else a
// free slot, else a brand new lane on the right.
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);
}
// Lanes that were waiting for this commit converge into it.
for (let k = 0; k < after.length; k += 1) {
if (after[k] === commit.hash) {
after[k] = null;
}
}
// The commit continues along its first parent in the same column.
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
// Slots that leave the commit dot (first parent + extra merge parents).
const fromCommit = new Set<number>([col]);
for (let p = 1; p < commit.parents.length; p += 1) {
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 += 1) {
const target = before[k];
if (target == null) {
continue;
}
const toCol = target === commit.hash ? col : k;
top.push({ fromCol: k, toCol, color: laneColor(k) });
}
const bottom: GraphSegment[] = [];
for (let k = 0; k < after.length; k += 1) {
if (after[k] == null) {
continue;
}
const fromCol = fromCommit.has(k) ? col : k;
bottom.push({ fromCol, toCol: k, color: laneColor(k) });
}
rows.push({ dotCol: col, dotColor: laneColor(col), top, bottom });
// Trim trailing empty lanes so the graph stays compact.
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 displayLine(line: string): string {
// Strip a trailing CR for display only; the stored line keeps it so the
// rebuilt file preserves its original line endings.
@@ -786,6 +1006,7 @@
const first = conflictedFiles[0].path;
await runOperation("Loading conflicts", async () => {
preparedResolutions = {};
resolveDialogOpen = true;
await loadConflict(first);
});
@@ -801,29 +1022,59 @@
});
}
async function saveResolution() {
if (!activeRepoPath || !conflictTarget || isBusy) {
function chooseBinarySide(side: "ours" | "theirs") {
binarySide = side;
}
async function markCurrentResolved() {
if (!conflict || !conflictTarget || !canMarkResolved) {
return;
}
if (
resolveHasMarkers &&
!window.confirm(
"Conflict markers (<<<<<<< / >>>>>>>) are still present. Mark this file as resolved anyway?",
)
) {
return;
}
const prepared: PreparedResolution = conflict.binary
? { kind: "side", side: binarySide as "ours" | "theirs" }
: { kind: "content", content: resolvedContent };
const resolvedPath = conflictTarget;
const content = resolvedContent;
await runOperation(`Resolving ${resolvedPath}`, async () => {
const nextStatus = await resolveConflict(activeRepoPath, resolvedPath, content);
applyStatus(nextStatus);
preparedResolutions = { ...preparedResolutions, [resolvedPath]: prepared };
// Jump to the next file that still needs a decision, if any.
const next = conflictedFiles.find(
(file) => file.path !== resolvedPath && preparedResolutions[file.path] == null,
);
if (next) {
await runOperation(`Loading ${next.path}`, async () => {
await loadConflict(next.path);
});
}
}
async function applyPreparedResolutions() {
if (!activeRepoPath || isBusy || preparedCount === 0) {
return;
}
const entries = Object.entries(preparedResolutions);
await runOperation(`Resolving ${entries.length} ${entries.length === 1 ? "file" : "files"}`, async () => {
let nextStatus: GitStatus | null = null;
for (const [file, prepared] of entries) {
nextStatus =
prepared.kind === "side"
? await resolveConflictSide(activeRepoPath, file, prepared.side)
: await resolveConflict(activeRepoPath, file, prepared.content);
}
preparedResolutions = {};
if (nextStatus) {
applyStatus(nextStatus);
}
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
const remaining = nextStatus.files.filter(
const remaining = (nextStatus?.files ?? status?.files ?? []).filter(
(file) => file.staged === "conflicted" || file.unstaged === "conflicted",
);
@@ -835,12 +1086,26 @@
conflictParts = [];
conflictChoices = [];
manualMode = false;
binarySide = null;
} else {
await loadConflict(remaining[0].path);
}
});
}
function formatBytes(size: number | null): string {
if (size == null) {
return "missing";
}
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function closeResolveDialog() {
resolveDialogOpen = false;
}
@@ -997,6 +1262,16 @@
return `${file.old_path} -> ${file.path}`;
}
function toggleCommitFiles(hash: string) {
const next = new Set(expandedCommitHashes);
if (next.has(hash)) {
next.delete(hash);
} else {
next.add(hash);
}
expandedCommitHashes = next;
}
function displayCommitFile(file: GitCommitFile): string {
if (!file.old_path) {
return file.path;
@@ -1062,6 +1337,19 @@
<RefreshCw class={operation === "Refreshing" ? "spin" : ""} size={16} aria-hidden="true" />
Refresh
</button>
<button
type="button"
class="auto-refresh-toggle"
class:active={autoRefreshEnabled}
onclick={toggleAutoRefresh}
aria-pressed={autoRefreshEnabled}
title={autoRefreshEnabled
? "Auto refresh is on — changes appear automatically"
: "Auto refresh is off"}
>
<RefreshCw class={autoRefreshInFlight ? "spin" : ""} size={16} aria-hidden="true" />
Auto {autoRefreshEnabled ? "on" : "off"}
</button>
</div>
</header>
@@ -1103,40 +1391,60 @@
<span class="counter">{branches.length}</span>
</div>
{#snippet branchRow(branch: GitBranchInfo)}
<article class:current={branch.current} class="branch-row">
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>{branch.remote ? "remote" : "local"}</span>
</div>
</div>
{#if branch.current}
<span class="active-pill">Current</span>
{:else}
<div class="branch-actions">
<button type="button" onclick={() => checkout(branch)} disabled={isBusy}>Checkout</button>
<button
type="button"
onclick={() => merge(branch)}
disabled={isBusy}
title="Merge branch into current branch"
>
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/snippet}
{#if !hasRepository}
<p class="empty-note">Open a repository to list branches.</p>
{:else if branches.length === 0}
<p class="empty-note">No branches returned.</p>
{:else}
<div class="branch-list">
{#each branches as branch (branch.name)}
<article class:current={branch.current} class="branch-row">
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>{branch.remote ? "remote" : "local"}</span>
</div>
</div>
{#if localBranches.length > 0}
<div class="branch-group-label">
<span>Local</span>
<span class="branch-group-count">{localBranches.length}</span>
</div>
{#each localBranches as branch (branch.name)}
{@render branchRow(branch)}
{/each}
{/if}
{#if branch.current}
<span class="active-pill">Current</span>
{:else}
<div class="branch-actions">
<button type="button" onclick={() => checkout(branch)} disabled={isBusy}>Checkout</button>
<button
type="button"
onclick={() => merge(branch)}
disabled={isBusy}
title="Merge branch into current branch"
>
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/each}
{#if remoteBranches.length > 0}
<div class="branch-group-label">
<span>Remote</span>
<span class="branch-group-count">{remoteBranches.length}</span>
</div>
{#each remoteBranches as branch (branch.name)}
{@render branchRow(branch)}
{/each}
{/if}
</div>
{/if}
</section>
@@ -1393,73 +1701,6 @@
{/if}
</section>
<section class="history-panel" aria-label="Commit history">
<div class="section-heading">
<div>
<span class="eyebrow">History</span>
<h2>Commits</h2>
</div>
<span class="counter">{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">
{#each commits as item (item.hash)}
<article class="commit-row">
<div class="commit-line">
<History size={16} aria-hidden="true" />
<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-file-list" aria-label="Changed files">
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
<button
type="button"
class="commit-file-button"
onclick={() => restoreCommitFile(item, file)}
disabled={isBusy}
title="Restore this file from this commit"
>
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
<strong>{displayCommitFile(file)}</strong>
</button>
{/each}
</div>
{/if}
<div class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<button
type="button"
onclick={() => restoreCommit(item)}
disabled={isBusy}
title="Reset current branch to this commit"
>
<RotateCcw size={15} aria-hidden="true" />
Restore
</button>
</div>
</article>
{/each}
</div>
{/if}
</section>
</aside>
</div>
@@ -1533,6 +1774,137 @@
{/if}
</section>
</section>
<aside class="history-aside" aria-label="Commit history">
<section class="history-panel" aria-label="Commit history">
<div class="section-heading">
<div>
<span class="eyebrow">History</span>
<h2>Commits</h2>
</div>
<span class="counter">{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">
{#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
type="button"
class="commit-files-toggle"
onclick={() => toggleCommitFiles(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
type="button"
class="commit-file-button"
onclick={() => restoreCommitFile(item, file)}
disabled={isBusy}
title="Restore this file from this commit"
>
<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
type="button"
onclick={() => restoreCommit(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>
</aside>
</section>
{#if compareDialogOpen && comparison}
@@ -1641,13 +2013,19 @@
<button
type="button"
class:active={conflictTarget === file.path}
class:prepared={preparedResolutions[file.path] != null}
class="dialog-file-row"
onclick={() => selectConflictFile(file.path)}
disabled={isBusy}
title={file.path}
>
<span class="status-badge conflicted">conflicted</span>
<span class={`status-badge ${preparedResolutions[file.path] ? "added" : "conflicted"}`}>
{preparedResolutions[file.path] ? "ready" : "conflicted"}
</span>
<strong>{file.path}</strong>
{#if preparedResolutions[file.path]}
<Check size={15} aria-hidden="true" />
{/if}
</button>
{/each}
</aside>
@@ -1656,7 +2034,48 @@
{#if !conflict}
<div class="blank-state">Select a file to resolve.</div>
{:else}
<div class="resolve-toolbar">
{#if conflict.binary}
<div class="resolve-binary">
<div class="resolve-binary-note">
<AlertCircle size={16} aria-hidden="true" />
<span>
Binary file — it cannot be merged line by line. Pick which version
to keep, then mark it resolved.
</span>
</div>
<div class="resolve-binary-options">
<button
type="button"
class="resolve-binary-card ours"
class:active={binarySide === "ours"}
onclick={() => chooseBinarySide("ours")}
disabled={isBusy || conflict.ours_size == null}
>
<span class="resolve-side-label">Current (ours)</span>
<strong>{formatBytes(conflict.ours_size)}</strong>
<span class="resolve-binary-hint">
{conflict.ours_size == null ? "Deleted on this side" : "Keep this version"}
</span>
</button>
<button
type="button"
class="resolve-binary-card theirs"
class:active={binarySide === "theirs"}
onclick={() => chooseBinarySide("theirs")}
disabled={isBusy || conflict.theirs_size == null}
>
<span class="resolve-side-label">Incoming (theirs)</span>
<strong>{formatBytes(conflict.theirs_size)}</strong>
<span class="resolve-binary-hint">
{conflict.theirs_size == null ? "Deleted on this side" : "Keep this version"}
</span>
</button>
</div>
</div>
{:else}
<div class="resolve-toolbar">
{#if manualMode}
<button type="button" onclick={disableManualEdit} disabled={isBusy}>
Back to guided
@@ -1773,27 +2192,49 @@
{/if}
{/each}
</div>
{/if}
{/if}
<div class="resolve-actions">
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
{#if currentPrepared}
<span class="prepared-tag">
<Check size={14} aria-hidden="true" />
Prepared
</span>
{/if}
<button
class="primary-button"
type="button"
onclick={saveResolution}
disabled={isBusy}
onclick={markCurrentResolved}
disabled={!canMarkResolved}
title="Prepare this file's resolution (applied with Apply resolved)"
>
{#if operation.startsWith("Resolving")}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Save &amp; mark resolved
<Check size={16} aria-hidden="true" />
{currentPrepared ? "Update decision" : "Mark as resolved"}
</button>
</div>
{/if}
</div>
</div>
<footer class="dialog-footer">
<span class="dialog-footer-info">
{preparedCount} of {conflictedFiles.length} prepared
</span>
<button
class="primary-button"
type="button"
onclick={applyPreparedResolutions}
disabled={isBusy || preparedCount === 0}
>
{#if operation.startsWith("Resolving")}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Apply {preparedCount} resolved
</button>
</footer>
{/if}
</div>
</div>