add more features

This commit is contained in:
Christoph Brandau
2026-06-27 13:49:56 +02:00
parent 3b9d636fdb
commit 6a78e768bc
7 changed files with 989 additions and 18 deletions
+388 -9
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {
AlertCircle,
ArrowRight,
ChevronDown,
ChevronRight,
Check,
@@ -9,6 +10,7 @@
Folder,
FolderOpen,
GitBranch,
GitCompare,
GitMerge,
History,
LoaderCircle,
@@ -16,11 +18,14 @@
RotateCcw,
Undo2,
Upload,
X,
} from "@lucide/svelte";
import {
checkoutBranch,
commit,
compareCommits,
diffFileAgainstWorkingTree,
getStatus,
listBranches,
listCommits,
@@ -40,7 +45,9 @@
FileStatusKind,
GitBranch as GitBranchInfo,
GitCommit,
GitCommitComparison,
GitCommitFile,
GitDiffFile,
GitFileStatus,
GitRepositoryFile,
GitStatus,
@@ -71,6 +78,11 @@
let commitMessage = "";
let errorMessage = "";
let operation = "";
let compareFrom = "";
let compareTo = "";
let comparison: GitCommitComparison | null = null;
let compareDialogOpen = false;
let selectedDiffPath = "";
$: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null;
@@ -78,6 +90,15 @@
$: stagedCount = status?.files.filter((file) => file.staged !== null).length ?? 0;
$: unstagedCount = status?.files.filter((file) => file.unstaged !== null).length ?? 0;
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !isBusy;
$: canCompare =
hasRepository &&
compareFrom.length > 0 &&
compareTo.length > 0 &&
compareFrom !== compareTo &&
!isBusy;
$: diffByPath = comparison ? buildDiffByPath(comparison.patch) : new Map<string, string>();
$: selectedDiffFile = comparison?.files.find((file) => file.path === selectedDiffPath) ?? null;
$: selectedDiffPatch = selectedDiffFile ? diffByPath.get(selectedDiffFile.path) ?? "" : "";
$: explorerTree = buildExplorerTree(repoFiles);
$: visibleExplorerNodes = flattenExplorerTree(explorerTree, expandedExplorerPaths);
$: selectedExplorerLabel = selectedExplorerPath
@@ -264,6 +285,31 @@
async function refreshCommitHistory(path = activeRepoPath) {
commits = await listCommits(path, 100);
reconcileCompareSelection();
}
function reconcileCompareSelection() {
const hashes = new Set(commits.map((item) => item.hash));
if (compareFrom && !hashes.has(compareFrom)) {
compareFrom = "";
}
if (compareTo && !hashes.has(compareTo)) {
compareTo = "";
}
// Only reconcile commit-to-commit comparisons; a working-tree diff has an
// empty `to_hash` and should stay until the user closes it.
if (
comparison &&
comparison.to_hash.length > 0 &&
(!hashes.has(comparison.from_hash) || !hashes.has(comparison.to_hash))
) {
comparison = null;
compareDialogOpen = false;
selectedDiffPath = "";
}
}
async function refreshExplorerFiles(path = activeRepoPath) {
@@ -302,6 +348,11 @@
selectedExplorerKind = "file";
expandedExplorerPaths = new Set<string>();
fileHistory = [];
compareFrom = "";
compareTo = "";
comparison = null;
compareDialogOpen = false;
selectedDiffPath = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
@@ -509,6 +560,181 @@
});
}
async function compareSelectedCommits() {
if (!canCompare) {
return;
}
await runOperation("Comparing commits", async () => {
const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
comparison = result;
selectedDiffPath = result.files[0]?.path ?? "";
compareDialogOpen = true;
});
}
function submitCompare(event: SubmitEvent) {
event.preventDefault();
void compareSelectedCommits();
}
async function diffSelectedFileFromCommit(historyCommit: GitCommit) {
if (!activeRepoPath || !selectedExplorerPath) {
return;
}
await runOperation(`Diffing ${selectedExplorerPath}`, async () => {
const result = await diffFileAgainstWorkingTree(
activeRepoPath,
historyCommit.hash,
selectedExplorerPath,
);
comparison = result;
selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath;
compareDialogOpen = true;
});
}
function openCompareDialog() {
if (comparison) {
compareDialogOpen = true;
}
}
function closeCompareDialog() {
compareDialogOpen = false;
}
function selectDiffFile(file: GitDiffFile) {
selectedDiffPath = file.path;
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) {
closeCompareDialog();
}
}
function buildDiffByPath(patch: string): Map<string, string> {
const segments = splitPatchByFile(patch);
const map = new Map<string, string>();
for (const segment of segments) {
const path = patchFilePath(segment);
if (path) {
map.set(path, segment);
}
}
return map;
}
function splitPatchByFile(patch: string): string[] {
if (!patch.trim()) {
return [];
}
const segments: string[] = [];
let current: string[] = [];
for (const line of patch.split("\n")) {
if (line.startsWith("diff --git ") && current.length > 0) {
segments.push(current.join("\n"));
current = [];
}
current.push(line);
}
if (current.length > 0) {
segments.push(current.join("\n"));
}
return segments;
}
function patchFilePath(segment: string): string {
let plusPath = "";
let minusPath = "";
for (const line of segment.split("\n")) {
if (line.startsWith("+++ ")) {
plusPath = stripDiffPathPrefix(line.slice(4));
} else if (line.startsWith("--- ")) {
minusPath = stripDiffPathPrefix(line.slice(4));
} else if (line.startsWith("@@")) {
break;
}
}
if (plusPath && plusPath !== "/dev/null") {
return plusPath;
}
return minusPath;
}
function stripDiffPathPrefix(value: string): string {
const trimmed = value.trim();
if (trimmed === "/dev/null") {
return trimmed;
}
if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) {
return trimmed.slice(2);
}
return trimmed;
}
function commitOptionLabel(item: GitCommit): string {
return `${item.short_hash} - ${item.summary}`;
}
function displayDiffFile(file: GitDiffFile): string {
if (!file.old_path) {
return file.path;
}
return `${file.old_path} -> ${file.path}`;
}
type DiffLineKind = "meta" | "hunk" | "add" | "del" | "context";
function diffLineKind(line: string): DiffLineKind {
if (
line.startsWith("diff ") ||
line.startsWith("index ") ||
line.startsWith("--- ") ||
line.startsWith("+++ ") ||
line.startsWith("new file") ||
line.startsWith("deleted file") ||
line.startsWith("rename ") ||
line.startsWith("similarity ")
) {
return "meta";
}
if (line.startsWith("@@")) {
return "hunk";
}
if (line.startsWith("+")) {
return "add";
}
if (line.startsWith("-")) {
return "del";
}
return "context";
}
function diffLines(patch: string): { kind: DiffLineKind; text: string }[] {
const lines = patch.replace(/\n$/, "").split("\n");
return lines.map((text) => ({ kind: diffLineKind(text), text }));
}
function submitRepo(event: SubmitEvent) {
event.preventDefault();
void openRepo();
@@ -883,15 +1109,26 @@
<div class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<button
type="button"
onclick={() => restoreSelectedFileFromCommit(item)}
disabled={isBusy}
title="Restore selected file from this commit"
>
<RotateCcw size={15} aria-hidden="true" />
Restore
</button>
<div class="commit-action-buttons">
<button
type="button"
onclick={() => diffSelectedFileFromCommit(item)}
disabled={isBusy}
title="Show changes between this commit and the current working tree"
>
<GitCompare size={15} aria-hidden="true" />
Diff
</button>
<button
type="button"
onclick={() => restoreSelectedFileFromCommit(item)}
disabled={isBusy}
title="Restore selected file from this commit"
>
<RotateCcw size={15} aria-hidden="true" />
Restore
</button>
</div>
</div>
</article>
{/each}
@@ -968,6 +1205,148 @@
</section>
</aside>
</div>
<section class="compare-panel" aria-label="Compare commits">
<div class="section-heading">
<div>
<span class="eyebrow">Compare</span>
<h2>Commits</h2>
</div>
{#if comparison}
<span class="counter">{comparison.files.length} files</span>
{/if}
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if commits.length < 2}
<div class="blank-state">At least two commits are needed to compare.</div>
{:else}
<form class="compare-form" onsubmit={submitCompare}>
<label class="compare-field">
<span>From (older)</span>
<select bind:value={compareFrom} disabled={isBusy}>
<option value="" disabled>Select a commit</option>
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</select>
</label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
<label class="compare-field">
<span>To (newer)</span>
<select bind:value={compareTo} disabled={isBusy}>
<option value="" disabled>Select a commit</option>
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</select>
</label>
<button class="primary-button" type="submit" disabled={!canCompare}>
{#if operation === "Comparing commits"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitCompare size={16} aria-hidden="true" />
{/if}
Compare
</button>
</form>
{#if compareFrom && compareTo && compareFrom === compareTo}
<div class="blank-state">Select two different commits to compare.</div>
{:else if comparison}
<div class="compare-summary">
<div class="compare-range">
<span class="hash">{comparison.from_short}</span>
<ArrowRight size={15} aria-hidden="true" />
<span class="hash">{comparison.to_short}</span>
<span class="compare-count">{comparison.files.length} changed files</span>
</div>
<button type="button" onclick={openCompareDialog} disabled={isBusy}>
<GitCompare size={15} aria-hidden="true" />
View comparison
</button>
</div>
{:else}
<div class="blank-state">Pick two commits and run a comparison.</div>
{/if}
{/if}
</section>
</section>
</section>
{#if compareDialogOpen && comparison}
<div
class="dialog-backdrop"
role="presentation"
onclick={(event) => {
if (event.target === event.currentTarget) {
closeCompareDialog();
}
}}
>
<div
class="dialog"
role="dialog"
aria-modal="true"
aria-label="Commit comparison"
tabindex="-1"
>
<header class="dialog-header">
<div>
<span class="eyebrow">Compare</span>
<h2 class="dialog-range">
<span class="hash">{comparison.from_short}</span>
<ArrowRight size={16} aria-hidden="true" />
<span class="hash">{comparison.to_short}</span>
</h2>
</div>
<button type="button" class="dialog-close" onclick={closeCompareDialog} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
{#if comparison.files.length === 0}
<div class="blank-state">No differences to show — these versions are identical.</div>
{:else}
<div class="dialog-body">
<aside class="dialog-files" aria-label="Changed files">
{#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
<button
type="button"
class:active={selectedDiffPath === file.path}
class="dialog-file-row"
onclick={() => selectDiffFile(file)}
title={displayDiffFile(file)}
>
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
<strong>{displayDiffFile(file)}</strong>
<span class="diff-counts">
<span class="adds">+{file.additions}</span>
<span class="dels">-{file.deletions}</span>
</span>
</button>
{/each}
</aside>
<div class="dialog-diff" aria-label="File diff">
{#if !selectedDiffFile}
<div class="blank-state">Select a file to see its changes.</div>
{:else if selectedDiffPatch.trim().length === 0}
<div class="blank-state">No textual changes for this file.</div>
{:else}
<pre class="diff-view" aria-label="Unified diff">{#each diffLines(selectedDiffPatch) as line}<span
class={`diff-line ${line.kind}`}>{line.text || " "}</span>{/each}</pre>
{/if}
</div>
</div>
{/if}
</div>
</div>
{/if}
</main>
<svelte:window on:keydown={handleWindowKeydown} />