Optimize repository loading and enhance Git UI

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.
This commit is contained in:
Christoph Brandau
2026-07-01 11:39:22 +02:00
parent 628e2f7c0b
commit fe392d38bf
12 changed files with 528 additions and 56 deletions
+89 -39
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { onDestroy, onMount, tick } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
import { AlertCircle, Check, FolderOpen, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte";
@@ -8,12 +8,13 @@
import BranchPanel from "./lib/components/BranchPanel.svelte";
import CommitPanel from "./lib/components/CommitPanel.svelte";
import CompareDialog from "./lib/components/CompareDialog.svelte";
import ComparePanel from "./lib/components/ComparePanel.svelte";
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte";
@@ -33,7 +34,7 @@
listFileHistory,
listRepositoryFiles,
mergeBranch,
openRepository,
openRepositoryBundle,
pull,
push,
getRemoteUrl,
@@ -96,6 +97,8 @@
let compareFrom = "";
let compareTo = "";
let comparison: GitCommitComparison | null = null;
let newBranchCommit: GitCommit | null = null;
let compareSelectOpen = false;
let compareDialogOpen = false;
let selectedDiffPath = "";
let diffHighlightQuery = "";
@@ -167,15 +170,18 @@
}
async function autoRefreshTick() {
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || globalSearchOpen) return;
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
autoRefreshInFlight = true;
try {
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
const nextStatus = await getStatus(activeRepoPath);
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
applyStatus(nextStatus);
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
// Something changed — reload branches, commits and files in one bundled call.
const bundle = await openRepositoryBundle(activeRepoPath, 100);
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
await refreshFileHistory(activeRepoPath);
} catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false;
@@ -321,12 +327,12 @@
// ── Refresh helpers ────────────────────────────────────────────────────────
async function refreshBranchList(path = activeRepoPath) {
branches = await listBranches(path);
async function refreshBranchList(path = activeRepoPath, prefetched?: GitBranchInfo[]) {
branches = prefetched ?? (await listBranches(path));
}
async function refreshCommitHistory(path = activeRepoPath) {
commits = await listCommits(path, 100);
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100));
const hashes = new Set(commits.map((c) => c.hash));
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
if (compareTo && !hashes.has(compareTo)) compareTo = "";
@@ -338,8 +344,8 @@
}
}
async function refreshExplorerFiles(path = activeRepoPath) {
repoFiles = await listRepositoryFiles(path);
async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
repoFiles = prefetched ?? (await listRepositoryFiles(path));
const folderPaths = allExplorerFolderPaths(repoFiles);
expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder)));
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
@@ -361,20 +367,28 @@
repoPath = path;
await runOperation("Opening repository", async () => {
const nextStatus = await openRepository(path);
applyStatus(nextStatus);
// Paint the loading overlay before the (potentially slow) git enumeration
// starts — otherwise the first paint is deferred until the bundle resolves
// and the overlay appears to "come late".
await tick();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
// Single backend round-trip: resolves the repo and reads status, branches,
// commits and files in one pass instead of four sequential git calls.
const bundle = await openRepositoryBundle(path, 100);
applyStatus(bundle.status);
branches = []; commits = []; repoFiles = [];
selectedExplorerPath = ""; selectedExplorerKind = "file";
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
fileHistory = []; compareFrom = ""; compareTo = "";
comparison = null; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
comparison = null; compareSelectOpen = false; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
newBranchCommit = null;
if (globalSearchBusy) void cancelGlobalSearch();
globalSearchResults = []; globalSearchOpen = false; globalSearchError = "";
resolveDialogOpen = false; conflictTarget = ""; conflict = null;
preparedResolutions = {};
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
});
}
@@ -429,6 +443,25 @@
});
}
function openNewBranchDialog(commit: GitCommit) {
if (!activeRepoPath || isBusy) return;
newBranchCommit = commit;
}
async function createBranchFromCommit(branchName: string) {
const target = newBranchCommit;
const name = branchName.trim();
if (!activeRepoPath || !target || !name) return;
await runOperation(`Creating ${name}`, async () => {
applyStatus(await createBranch(activeRepoPath, name, target.hash));
newBranchCommit = null;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function merge(branch: GitBranchInfo) {
if (!activeRepoPath || branch.current) return;
await runOperation(`Merging ${branch.name}`, async () => {
@@ -780,6 +813,11 @@
// ── Compare ────────────────────────────────────────────────────────────────
function openCompareSelect() {
if (!hasRepository) return;
compareSelectOpen = true;
}
async function compareSelectedCommits() {
if (!canCompare) return;
await runOperation("Comparing commits", async () => {
@@ -788,6 +826,7 @@
selectedDiffPath = result.files[0]?.path ?? "";
diffHighlightQuery = "";
pendingRestoreFile = null;
compareSelectOpen = false;
compareDialogOpen = true;
});
}
@@ -816,10 +855,6 @@
});
}
function openCompareDialog() {
if (comparison) compareDialogOpen = true;
}
function closeCompareDialog() {
compareDialogOpen = false;
pendingRestoreFile = null;
@@ -947,6 +982,8 @@
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
}
</script>
@@ -972,6 +1009,7 @@
onPush={pushRepo}
onRefresh={refreshRepo}
onSearch={() => { globalSearchOpen = true; }}
onCompare={openCompareSelect}
onToggleAutoRefresh={toggleAutoRefresh}
/>
@@ -1063,7 +1101,7 @@
/>
</aside>
<!-- Center: summary + status + commit + compare -->
<!-- Center: summary + status + commit -->
<section class="main-panel" aria-label="Repository status">
<div class="repo-summary">
<div class="repo-meta">
@@ -1106,21 +1144,6 @@
onCommitMessageChange={(msg) => { commitMessage = msg; }}
/>
</div>
<ComparePanel
{commits}
{hasRepository}
{isBusy}
{compareFrom}
{compareTo}
{canCompare}
{comparison}
{operation}
onCompareFromChange={(val) => { compareFrom = val; }}
onCompareToChange={(val) => { compareTo = val; }}
onCompare={compareSelectedCommits}
onOpenDialog={openCompareDialog}
/>
</section>
<!-- Right sidebar: commit graph + file history -->
@@ -1132,6 +1155,7 @@
{expandedCommitHashes}
onRestoreCommit={restoreCommit}
onPreviewCommitFile={previewCommitFileFromHistory}
onCreateBranchFromCommit={openNewBranchDialog}
onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash);
@@ -1185,6 +1209,32 @@
/>
{/if}
<!-- Create a branch from a specific commit in the history -->
{#if newBranchCommit}
<NewBranchDialog
commit={newBranchCommit}
{isBusy}
onCreate={createBranchFromCommit}
onClose={() => { newBranchCommit = null; }}
/>
{/if}
<!-- Compare: pick the two commits to diff -->
{#if compareSelectOpen}
<CompareSelectDialog
{commits}
{compareFrom}
{compareTo}
{canCompare}
{isBusy}
{operation}
onCompareFromChange={(val) => { compareFrom = val; }}
onCompareToChange={(val) => { compareTo = val; }}
onCompare={compareSelectedCommits}
onClose={() => { compareSelectOpen = false; }}
/>
{/if}
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
{#if compareDialogOpen && comparison}
<CompareDialog
+52 -3
View File
@@ -601,7 +601,7 @@
.main-panel {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-rows: auto minmax(0, 1fr);
min-width: 0;
min-height: 0;
border: 1px solid var(--color-border);
@@ -664,7 +664,8 @@
.top-section {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) minmax(210px, auto);
min-height: 0;
gap: 8px;
padding: 8px;
@@ -1093,6 +1094,55 @@
width: min(1180px, calc(100vw - 32px));
height: min(840px, calc(100vh - 32px));
}
.compare-select-dialog {
display: block;
width: min(720px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.new-branch-dialog {
display: block;
width: min(520px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.new-branch-form {
display: flex;
flex-direction: column;
gap: 14px;
padding: 16px;
}
.new-branch-target {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid var(--color-border-subtle);
border-radius: 8px;
background: var(--color-surface-raised);
}
.new-branch-summary {
overflow: hidden;
color: var(--color-ink-dim);
text-overflow: ellipsis;
white-space: nowrap;
}
.new-branch-field { display: grid; gap: 6px; }
.new-branch-field span {
color: var(--color-ink-faint);
font-size: 10.5px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.new-branch-actions {
display: flex;
justify-content: flex-end;
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 > div:first-child { min-width: 0; }
@@ -2182,7 +2232,6 @@
@media (min-width: 1800px) {
.workspace { grid-template-columns: 320px minmax(0, 1fr) 680px; }
.top-section { grid-template-columns: minmax(0, 1fr) 380px; }
}
@media (max-width: 1400px) {
+13 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
import { Download, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
export let branch: string = "";
export let ahead: number = 0;
@@ -16,6 +16,7 @@
export let onPush: () => void = () => {};
export let onRefresh: () => void = () => {};
export let onSearch: () => void = () => {};
export let onCompare: () => void = () => {};
export let onToggleAutoRefresh: () => void = () => {};
const win = getCurrentWindow();
@@ -92,6 +93,17 @@
<span class="tb-action-label">Search</span>
</button>
<button
class="tb-action"
onclick={onCompare}
disabled={!hasRepository || isBusy}
title="Compare commits"
aria-label="Compare commits"
>
<GitCompare size={14} aria-hidden="true" />
<span class="tb-action-label">Compare</span>
</button>
<button
class="tb-action"
onclick={onPull}
@@ -0,0 +1,108 @@
<script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
interface Props {
commits: GitCommit[];
compareFrom: string;
compareTo: string;
canCompare: boolean;
isBusy: boolean;
operation: string;
onCompareFromChange: (val: string) => void;
onCompareToChange: (val: string) => void;
onCompare: () => void;
onClose: () => void;
}
let {
commits = [],
compareFrom = "",
compareTo = "",
canCompare = false,
isBusy = false,
operation = "",
onCompareFromChange = () => {},
onCompareToChange = () => {},
onCompare = () => {},
onClose = () => {},
}: Props = $props();
function commitOptionLabel(item: GitCommit): string {
return `${item.short_hash} - ${item.summary}`;
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
onCompare();
}
</script>
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">Compare</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
{#if commits.length < 2}
<div class="blank-state">At least two commits are needed to compare.</div>
{:else}
<form class="compare-form" onsubmit={handleSubmit}>
<label class="compare-field">
<span>From (older)</span>
<select
value={compareFrom}
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
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
value={compareTo}
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
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="btn-primary" 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}
<div class="blank-state">Pick two commits and run a comparison.</div>
{/if}
{/if}
</div>
</div>
+7 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { ChevronDown, ChevronRight, RotateCcw } from "@lucide/svelte";
import { ChevronDown, ChevronRight, GitBranch, RotateCcw } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
@@ -29,6 +29,7 @@
onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
onCreateBranchFromCommit: (commit: GitCommit) => void;
}
let {
@@ -39,6 +40,7 @@
onRestoreCommit = () => {},
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
onCreateBranchFromCommit = () => {},
}: Props = $props();
function laneColor(col: number): string {
@@ -220,6 +222,10 @@
<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
+79
View File
@@ -0,0 +1,79 @@
<script lang="ts">
import { GitBranch, LoaderCircle, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
interface Props {
commit: GitCommit;
isBusy: boolean;
onCreate: (name: string) => void;
onClose: () => void;
}
let {
commit,
isBusy = false,
onCreate = () => {},
onClose = () => {},
}: Props = $props();
let name = $state("");
function submit(event: SubmitEvent) {
event.preventDefault();
const value = name.trim();
if (!value) return;
onCreate(value);
}
</script>
<div
class="dialog-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
<header class="dialog-header">
<div>
<span class="eyebrow">New branch</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">From commit</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} title="Close">
<X size={18} aria-hidden="true" />
</button>
</header>
<form class="new-branch-form" onsubmit={submit}>
<div class="new-branch-target">
<span class="hash">{commit.short_hash}</span>
<span class="new-branch-summary" title={commit.summary}>{commit.summary}</span>
</div>
<label class="new-branch-field">
<span>Branch name</span>
<!-- svelte-ignore a11y_autofocus -->
<input
bind:value={name}
autocomplete="off"
spellcheck="false"
placeholder="feature/my-branch"
disabled={isBusy}
autofocus
/>
</label>
<div class="new-branch-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
Cancel
</button>
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0}>
{#if isBusy}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<GitBranch size={16} aria-hidden="true" />
{/if}
Create branch
</button>
</div>
</form>
</div>
</div>
+9 -1
View File
@@ -38,6 +38,14 @@
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
}
function baseName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function fileName(file: GitFileStatus): string {
return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path);
}
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
</script>
@@ -90,7 +98,7 @@
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
<article class="file-row">
<div class="file-title">
<strong title={displayPath(file)}>{displayPath(file)}</strong>
<strong title={displayPath(file)}>{fileName(file)}</strong>
</div>
<div class="change-lanes">
+11 -2
View File
@@ -8,6 +8,7 @@ import type {
GitRepositoryFile,
GitSearchHit,
GitStatus,
RepositoryBundle,
StoredCredential,
} from "./types";
@@ -15,6 +16,10 @@ export function openRepository(path: string): Promise<GitStatus> {
return invoke<GitStatus>("open_repository", { path });
}
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
}
export function getStatus(path: string): Promise<GitStatus> {
return invoke<GitStatus>("get_status", { path });
}
@@ -27,8 +32,12 @@ export function checkoutBranch(path: string, branch: string): Promise<GitStatus>
return invoke<GitStatus>("checkout_branch", { path, branch });
}
export function createBranch(path: string, branch: string): Promise<GitStatus> {
return invoke<GitStatus>("create_branch", { path, branch });
export function createBranch(
path: string,
branch: string,
startPoint?: string,
): Promise<GitStatus> {
return invoke<GitStatus>("create_branch", { path, branch, startPoint: startPoint ?? null });
}
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
+7
View File
@@ -54,6 +54,13 @@ export interface GitRepositoryFile {
status: FileStatusKind | null;
}
export interface RepositoryBundle {
status: GitStatus;
branches: GitBranch[];
commits: GitCommit[];
files: GitRepositoryFile[];
}
export interface GitDiffFile {
path: string;
old_path: string | null;