Files
GitLite/src/App.svelte
T
Christoph Brandau 8d56c3f39f refine ui
restore in a Dialog
2026-06-29 17:53:02 +02:00

903 lines
33 KiB
Svelte

<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import { AlertCircle, Check, FolderOpen, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte";
import TitleBar from "./lib/TitleBar.svelte";
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 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 ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte";
import {
checkoutBranch,
commit,
compareCommits,
cancelCodeSearch,
diffFileAgainstWorkingTree,
compareFileToHead,
getStatus,
listBranches,
listCommits,
listFileHistory,
listRepositoryFiles,
mergeBranch,
openRepository,
pull,
push,
readConflict,
resolveConflict,
resolveConflictSide,
restoreFileFromCommit,
restoreFiles,
restoreToCommit,
searchCodeIntroductions,
stageFiles,
unstageFiles,
} from "./lib/git";
import type {
ConflictFile,
ExplorerNode,
ExplorerNodeKind,
GitBranch as GitBranchInfo,
GitCommit,
GitCommitFile,
GitCommitComparison,
GitDiffFile,
GitFileStatus,
GitRepositoryFile,
GitSearchHit,
GitStatus,
PreparedResolution,
} from "./lib/types";
// ── State ──────────────────────────────────────────────────────────────────
let repoPath = "";
let activeRepoPath = "";
let status: GitStatus | null = null;
let branches: GitBranchInfo[] = [];
let commits: GitCommit[] = [];
let repoFiles: GitRepositoryFile[] = [];
let selectedExplorerPath = "";
let selectedExplorerKind: ExplorerNodeKind = "file";
let expandedExplorerPaths = new Set<string>();
let expandedCommitHashes = new Set<string>();
let fileHistory: GitCommit[] = [];
let commitMessage = "";
let errorMessage = "";
let operation = "";
let compareFrom = "";
let compareTo = "";
let comparison: GitCommitComparison | null = null;
let compareDialogOpen = false;
let selectedDiffPath = "";
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
let globalSearchOpen = false;
let globalSearchResults: GitSearchHit[] = [];
let globalSearchBusy = false;
let globalSearchError = "";
let globalSearchId = "";
let resolveDialogOpen = false;
let conflictTarget = "";
let conflict: ConflictFile | null = null;
let preparedResolutions: Record<string, PreparedResolution> = {};
let autoRefreshEnabled = true;
let autoRefreshInFlight = false;
let credDialogOpen = false;
let credDialogAction: "push" | "pull" | null = null;
let credDialogError = "";
let sessionCredentials: { username: string; password: string } | null = null;
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
// ── Derived ────────────────────────────────────────────────────────────────
$: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null;
$: changedFiles = status?.files ?? [];
$: stagedCount = status?.files.filter((f) => f.staged !== null).length ?? 0;
$: unstagedCount = status?.files.filter((f) => f.unstaged !== null).length ?? 0;
$: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted");
$: hasConflicts = conflictedFiles.length > 0;
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !isBusy;
$: commitBlockReason = hasConflicts
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "merge conflict must" : "merge conflicts must"} be resolved before committing.`
: "";
$: canCompare = hasRepository && compareFrom.length > 0 && compareTo.length > 0 && compareFrom !== compareTo && !isBusy;
$: localBranches = branches.filter((b) => !b.remote);
$: remoteBranches = branches.filter((b) => b.remote);
// ── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
});
onDestroy(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
});
// ── Auto-refresh ───────────────────────────────────────────────────────────
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 || globalSearchOpen) return;
autoRefreshInFlight = true;
try {
const nextStatus = await getStatus(activeRepoPath);
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
applyStatus(nextStatus);
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
} catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false;
}
}
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) void autoRefreshTick();
}
// ── Utilities ──────────────────────────────────────────────────────────────
function applyStatus(nextStatus: GitStatus) {
status = nextStatus;
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
repoPath = activeRepoPath;
lastStatusFingerprint = statusFingerprint(nextStatus);
}
function errorToMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
try { return JSON.stringify(error) ?? "Unknown error"; } catch { return "Unknown error"; }
}
async function runOperation(label: string, task: () => Promise<void>) {
if (isBusy) return;
operation = label;
errorMessage = "";
try { await task(); } catch (error) { errorMessage = errorToMessage(error); } finally { operation = ""; }
}
function normalizeExplorerPath(path: string): string {
return path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
}
function explorerPathExists(files: GitRepositoryFile[], path: string): boolean {
if (!path) return false;
const normalized = normalizeExplorerPath(path);
return files.some((f) => {
const fp = normalizeExplorerPath(f.path);
return fp === normalized || fp.startsWith(`${normalized}/`);
});
}
function defaultExpandedExplorerPaths(files: GitRepositoryFile[]): Set<string> {
const expanded = new Set<string>();
for (const file of files) {
const parts = file.path.split(/[\\/]+/).filter(Boolean);
if (parts.length > 1) expanded.add(parts[0]);
}
return expanded;
}
// ── Refresh helpers ────────────────────────────────────────────────────────
async function refreshBranchList(path = activeRepoPath) {
branches = await listBranches(path);
}
async function refreshCommitHistory(path = activeRepoPath) {
commits = 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 = "";
if (comparison && comparison.to_hash.length > 0 && (!hashes.has(comparison.from_hash) || !hashes.has(comparison.to_hash))) {
comparison = null;
compareDialogOpen = false;
selectedDiffPath = "";
pendingRestoreFile = null;
}
}
async function refreshExplorerFiles(path = activeRepoPath) {
repoFiles = await listRepositoryFiles(path);
if (expandedExplorerPaths.size === 0) expandedExplorerPaths = defaultExpandedExplorerPaths(repoFiles);
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
selectedExplorerPath = "";
selectedExplorerKind = "file";
fileHistory = [];
}
}
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
fileHistory = file ? await listFileHistory(path, file, 100) : [];
}
// ── Repository operations ──────────────────────────────────────────────────
async function openRepo(pathOverride?: string) {
const path = (pathOverride ?? repoPath).trim();
if (!path) { errorMessage = "Enter a repository path."; return; }
repoPath = path;
await runOperation("Opening repository", async () => {
const nextStatus = await openRepository(path);
applyStatus(nextStatus);
branches = []; commits = []; repoFiles = [];
selectedExplorerPath = ""; selectedExplorerKind = "file";
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
fileHistory = []; compareFrom = ""; compareTo = "";
comparison = null; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
if (globalSearchBusy) void cancelGlobalSearch();
globalSearchResults = []; globalSearchOpen = false; globalSearchError = "";
resolveDialogOpen = false; conflictTarget = ""; conflict = null;
preparedResolutions = {};
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
});
}
async function chooseRepositoryFolder() {
if (isBusy) return;
try {
const selected = await openDialog({
title: "Repository folder auswaehlen",
directory: true,
multiple: false,
defaultPath: repoPath.trim() || activeRepoPath || undefined,
});
if (typeof selected !== "string") return;
repoPath = selected;
await openRepo(selected);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
async function refreshRepo() {
if (!activeRepoPath) { await openRepo(); return; }
await runOperation("Refreshing", async () => {
applyStatus(await getStatus(activeRepoPath));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function checkout(branch: GitBranchInfo) {
if (!activeRepoPath || branch.current) return;
await runOperation(`Checking out ${branch.name}`, async () => {
applyStatus(await checkoutBranch(activeRepoPath, branch.name));
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 () => {
applyStatus(await mergeBranch(activeRepoPath, branch.name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
function openCredentialDialog(action: "push" | "pull") {
if (!activeRepoPath) return;
credDialogError = "";
credDialogAction = action;
credDialogOpen = true;
}
async function doActualPull(username: string, password: string) {
errorMessage = "";
await runOperation("Pulling", async () => {
applyStatus(await pull(activeRepoPath, username, password));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
if (errorMessage) { credDialogError = errorMessage; errorMessage = ""; }
else { credDialogOpen = false; credDialogAction = null; }
}
async function doActualPush(username: string, password: string) {
errorMessage = "";
await runOperation("Pushing", async () => {
applyStatus(await push(activeRepoPath, username, password));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
if (errorMessage) { credDialogError = errorMessage; errorMessage = ""; }
else { credDialogOpen = false; credDialogAction = null; }
}
async function handleCredentialSubmit(username: string, password: string, save: boolean) {
if (credDialogAction === "pull") await doActualPull(username, password);
else if (credDialogAction === "push") await doActualPush(username, password);
if (!credDialogOpen && save) sessionCredentials = { username, password };
}
async function pullRepo() {
if (!activeRepoPath) return;
if (sessionCredentials) {
await doActualPull(sessionCredentials.username, sessionCredentials.password);
} else {
openCredentialDialog("pull");
}
}
async function pushRepo() {
if (!activeRepoPath) return;
if (sessionCredentials) {
await doActualPush(sessionCredentials.username, sessionCredentials.password);
} else {
openCredentialDialog("push");
}
}
// ── File staging / restore ─────────────────────────────────────────────────
async function stageFile(file: GitFileStatus) {
await runOperation(`Staging ${file.path}`, async () => {
applyStatus(await stageFiles(activeRepoPath, [file.path]));
await refreshExplorerFiles(activeRepoPath);
});
}
async function unstageFile(file: GitFileStatus) {
await runOperation(`Unstaging ${file.path}`, async () => {
applyStatus(await unstageFiles(activeRepoPath, [file.path]));
await refreshExplorerFiles(activeRepoPath);
});
}
async function discardFile(file: GitFileStatus, staged: boolean) {
await runOperation(`Discarding ${file.path}`, async () => {
applyStatus(await restoreFiles(activeRepoPath, [file.path], staged));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function stageAllFiles() {
const paths = changedFiles.filter((f) => f.unstaged !== null).map((f) => f.path);
if (paths.length === 0) return;
await runOperation("Staging all", async () => {
applyStatus(await stageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath);
});
}
async function unstageAllFiles() {
const paths = changedFiles.filter((f) => f.staged !== null).map((f) => f.path);
if (paths.length === 0) return;
await runOperation("Unstaging all", async () => {
applyStatus(await unstageFiles(activeRepoPath, paths));
await refreshExplorerFiles(activeRepoPath);
});
}
async function commitChanges() {
const message = commitMessage.trim();
if (!message || !activeRepoPath) return;
if (hasConflicts) {
errorMessage = "Resolve all merge conflicts before committing.";
return;
}
await runOperation("Committing", async () => {
applyStatus(await commit(activeRepoPath, message));
commitMessage = "";
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
// ── Commit restore ─────────────────────────────────────────────────────────
async function restoreCommit(target: GitCommit) {
if (!activeRepoPath) return;
const confirmed = window.confirm(`Reset current branch to ${target.short_hash}?\n\nThis moves the current branch and discards tracked local changes.`);
if (!confirmed) return;
await runOperation(`Restoring ${target.short_hash}`, async () => {
applyStatus(await restoreToCommit(activeRepoPath, target.hash));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function restoreCommitFile(target: GitCommit, file: GitCommitFile): Promise<boolean> {
if (!activeRepoPath) return false;
const confirmed = window.confirm(`Restore ${file.path} from ${target.short_hash}?\n\nThis changes the file in your working tree so you can review and commit it.`);
if (!confirmed) return false;
await runOperation(`Restoring ${file.path}`, async () => {
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, file.path));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
return !errorMessage;
}
async function previewCommitFileFromHistory(target: GitCommit, file: GitCommitFile) {
if (!activeRepoPath) return;
await runOperation(`Diffing ${file.path}`, async () => {
const result = await compareFileToHead(activeRepoPath, target.hash, file.path);
const matchingFile = result.files.find((diffFile) =>
diffFile.path === file.path || diffFile.old_path === file.old_path || diffFile.old_path === file.path,
);
comparison = result;
selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path;
pendingRestoreFile = { commit: target, file };
compareDialogOpen = true;
});
}
// ── Explorer interaction ───────────────────────────────────────────────────
function toggleExplorerFolder(node: ExplorerNode) {
if (node.kind !== "folder") return;
const next = new Set(expandedExplorerPaths);
if (next.has(node.path)) next.delete(node.path); else next.add(node.path);
expandedExplorerPaths = next;
}
async function selectExplorerNode(node: ExplorerNode) {
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
selectedExplorerPath = node.path;
selectedExplorerKind = node.kind;
await runOperation(`Loading ${node.path} history`, async () => {
await refreshFileHistory(activeRepoPath, node.path);
});
}
async function restoreSelectedFileFromCommit(target: GitCommit) {
if (!activeRepoPath || !selectedExplorerPath) return;
const kind = selectedExplorerKind === "folder" ? "folder" : "file";
const confirmed = window.confirm(`Restore ${kind} ${selectedExplorerPath} from ${target.short_hash}?\n\nThis changes the selected ${kind} in your working tree so you can review and commit it.`);
if (!confirmed) return;
await runOperation(`Restoring ${selectedExplorerPath}`, async () => {
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
// ── Compare ────────────────────────────────────────────────────────────────
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 ?? "";
pendingRestoreFile = null;
compareDialogOpen = true;
});
}
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;
pendingRestoreFile = null;
compareDialogOpen = true;
});
}
function openCompareDialog() {
if (comparison) compareDialogOpen = true;
}
function closeCompareDialog() {
compareDialogOpen = false;
pendingRestoreFile = null;
}
async function restorePreviewedCommitFile() {
if (!pendingRestoreFile) return;
const restored = await restoreCommitFile(pendingRestoreFile.commit, pendingRestoreFile.file);
if (restored) closeCompareDialog();
}
function selectDiffFile(file: GitDiffFile) {
selectedDiffPath = file.path;
}
async function runGlobalSearch(query: string, caseSensitive: boolean, limit: number) {
if (!activeRepoPath || globalSearchBusy) return;
const searchId = `search-${Date.now()}-${Math.random().toString(36).slice(2)}`;
globalSearchId = searchId;
globalSearchBusy = true;
globalSearchError = "";
globalSearchResults = [];
try {
const results = await searchCodeIntroductions(activeRepoPath, query, caseSensitive, limit, searchId);
if (globalSearchId === searchId) {
globalSearchResults = results;
}
} catch (error) {
if (globalSearchId === searchId) {
const message = errorToMessage(error);
globalSearchError = message.includes("abgebrochen") ? "Suche wurde abgebrochen." : message;
}
} finally {
if (globalSearchId === searchId) {
globalSearchBusy = false;
globalSearchId = "";
}
}
}
async function cancelGlobalSearch() {
if (!globalSearchId) return;
const searchId = globalSearchId;
globalSearchError = "Abbruch wird angefordert...";
try {
await cancelCodeSearch(searchId);
} catch (error) {
globalSearchError = errorToMessage(error);
}
}
function closeGlobalSearchDialog() {
if (globalSearchBusy) void cancelGlobalSearch();
globalSearchOpen = false;
}
// ── Conflict resolution ────────────────────────────────────────────────────
async function loadConflict(file: string) {
conflictTarget = file;
conflict = await readConflict(activeRepoPath, file);
}
async function openResolveDialog() {
if (!hasConflicts || isBusy) return;
const first = conflictedFiles[0].path;
await runOperation("Loading conflicts", async () => {
preparedResolutions = {};
resolveDialogOpen = true;
await loadConflict(first);
});
}
async function selectConflictFile(path: string) {
if (path === conflictTarget || isBusy) return;
await runOperation(`Loading ${path}`, async () => {
await loadConflict(path);
});
}
async function handleMarkResolved(path: string, resolution: PreparedResolution) {
preparedResolutions = { ...preparedResolutions, [path]: resolution };
const next = conflictedFiles.find((f) => f.path !== path && preparedResolutions[f.path] == null);
if (next) {
await runOperation(`Loading ${next.path}`, async () => {
await loadConflict(next.path);
});
}
}
async function applyPreparedResolutions() {
if (!activeRepoPath || isBusy || Object.keys(preparedResolutions).length === 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 ?? status?.files ?? []).filter(
(f) => f.staged === "conflicted" || f.unstaged === "conflicted",
);
if (remaining.length === 0) {
resolveDialogOpen = false;
conflict = null;
conflictTarget = "";
} else {
await loadConflict(remaining[0].path);
}
});
}
// ── Event handlers ─────────────────────────────────────────────────────────
function submitRepo(event: SubmitEvent) { event.preventDefault(); void openRepo(); }
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
}
</script>
<svelte:head>
<title>GitLite</title>
</svelte:head>
<svelte:window on:keydown={handleWindowKeydown} />
<main class="shell">
<TitleBar
branch={status?.current_branch ?? ""}
ahead={status?.ahead ?? 0}
behind={status?.behind ?? 0}
repoName={activeRepoPath ? (activeRepoPath.split(/[\\/]/).filter(Boolean).slice(-1)[0] ?? "") : ""}
{hasRepository}
{isBusy}
{operation}
{autoRefreshEnabled}
{autoRefreshInFlight}
onPull={pullRepo}
onPush={pushRepo}
onRefresh={refreshRepo}
onSearch={() => { globalSearchOpen = true; }}
onToggleAutoRefresh={toggleAutoRefresh}
/>
<div class="shell-body">
<!-- Repository path form -->
<header class="topbar">
<form class="repo-form" onsubmit={submitRepo}>
<label for="repo-path">Repository</label>
<input
id="repo-path"
bind:value={repoPath}
autocomplete="off"
spellcheck="false"
placeholder="/path/to/repository"
disabled={isBusy}
/>
<button
class="btn-secondary repo-browse"
type="button"
onclick={chooseRepositoryFolder}
disabled={isBusy}
title="Repository-Ordner auswaehlen"
aria-label="Repository-Ordner auswaehlen"
>
<FolderOpen size={16} aria-hidden="true" />
Browse
</button>
<button class="btn-primary" type="submit" disabled={isBusy || repoPath.trim().length === 0}>
{#if operation === "Opening repository"}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Open
</button>
</form>
</header>
<!-- Status notices -->
{#if errorMessage}
<section class="notice error" role="alert">
<AlertCircle size={17} aria-hidden="true" />
<span>{errorMessage}</span>
</section>
{/if}
{#if operation && operation !== "Opening repository"}
<section class="notice busy" aria-live="polite">
<LoaderCircle class="spin" size={17} aria-hidden="true" />
<span>{operation}</span>
</section>
{/if}
{#if hasConflicts}
<section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" />
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} merge conflicts.</span>
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
</section>
{/if}
<!-- Workspace -->
<section class="workspace" aria-label="Git workspace">
<!-- Left sidebar: branches + explorer -->
<aside class="left-sidebar" aria-label="Repository navigation">
<BranchPanel
{branches}
{localBranches}
{remoteBranches}
{hasRepository}
{isBusy}
onCheckout={checkout}
onMerge={merge}
/>
<ExplorerPanel
{repoFiles}
{expandedExplorerPaths}
{selectedExplorerPath}
{selectedExplorerKind}
{hasRepository}
{isBusy}
onToggleFolder={toggleExplorerFolder}
onSelectNode={selectExplorerNode}
/>
</aside>
<!-- Center: summary + status + commit + compare -->
<section class="main-panel" aria-label="Repository status">
<div class="repo-summary">
<div class="repo-meta">
<GitBranch size={13} aria-hidden="true" />
<strong class="repo-branch">{status?.current_branch ?? "No repository"}</strong>
{#if activeRepoPath}
<span class="repo-path" title={activeRepoPath}>{activeRepoPath}</span>
{/if}
</div>
<div class="sync-stats" aria-label="Sync state">
{#if status?.upstream}<span title="Upstream">{status.upstream}</span>{/if}
<strong>{status?.ahead ?? 0} ahead</strong>
<strong>{status?.behind ?? 0} behind</strong>
</div>
</div>
<div class="top-section">
<StatusPanel
{changedFiles}
{stagedCount}
{unstagedCount}
{hasRepository}
{isBusy}
{status}
onStage={stageFile}
onUnstage={unstageFile}
onDiscard={discardFile}
onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles}
/>
<CommitPanel
{commitMessage}
{canCommit}
{commitBlockReason}
{hasRepository}
{isBusy}
{operation}
{stagedCount}
onCommit={commitChanges}
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 -->
<aside class="history-aside" aria-label="Commit history">
<HistoryPanel
{commits}
{hasRepository}
{isBusy}
{expandedCommitHashes}
onRestoreCommit={restoreCommit}
onPreviewCommitFile={previewCommitFileFromHistory}
onToggleCommitFiles={(hash) => {
const next = new Set(expandedCommitHashes);
if (next.has(hash)) next.delete(hash); else next.add(hash);
expandedCommitHashes = next;
}}
/>
<FileHistoryPanel
{fileHistory}
{selectedExplorerPath}
selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"}
{hasRepository}
{isBusy}
onDiff={diffSelectedFileFromCommit}
onRestore={restoreSelectedFileFromCommit}
/>
</aside>
</section>
</div>
</main>
<!-- Compare diff dialog -->
{#if compareDialogOpen && comparison}
<CompareDialog
{comparison}
{selectedDiffPath}
{isBusy}
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
onClose={closeCompareDialog}
onRestore={restorePreviewedCommitFile}
onSelectFile={selectDiffFile}
/>
{/if}
{#if globalSearchOpen}
<GlobalSearchDialog
{hasRepository}
{isBusy}
isSearching={globalSearchBusy}
error={globalSearchError}
results={globalSearchResults}
onClose={closeGlobalSearchDialog}
onSearch={runGlobalSearch}
onCancel={cancelGlobalSearch}
/>
{/if}
<!-- Credential dialog for push/pull -->
{#if credDialogOpen && credDialogAction}
<CredentialDialog
action={credDialogAction}
error={credDialogError}
{isBusy}
onSubmit={handleCredentialSubmit}
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; }}
/>
{/if}
<!-- Conflict resolve dialog -->
{#if resolveDialogOpen}
<ResolveDialog
{conflictedFiles}
{conflictTarget}
{conflict}
{preparedResolutions}
{isBusy}
{operation}
onClose={() => { resolveDialogOpen = false; }}
onSelectFile={selectConflictFile}
onMarkResolved={handleMarkResolved}
onApply={applyPreparedResolutions}
/>
{/if}