Files
GitLite/src/App.svelte
T

1802 lines
64 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
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, BookOpen, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } 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 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 LinePatchDialog from "./lib/components/LinePatchDialog.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte";
import {
checkoutBranch,
commit,
compareCommits,
cancelCodeSearch,
applyFilePatch,
createBranch,
deleteBranch,
diffFileAgainstWorkingTree,
compareFileToParent,
getStatus,
listBranches,
listCommits,
listFileHistory,
listRepositoryFiles,
mergeBranch,
openRepoInExplorer,
openRepositoryBundle,
pull,
push,
renameBranch,
getRemoteUrl,
credLoad,
credSave,
credDelete,
getFilePatch,
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,
PatchApplyAction,
PreparedResolution,
StoredCredential,
} from "./lib/types";
import {
orgKeyFromUrl,
isCredentialExpired,
isAuthError,
stripAuthPrefix,
} from "./lib/credentials";
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
interface RepoTab {
path: string;
name: string;
branch: string | null;
ahead: number;
behind: number;
changed: number;
lastOpened: number;
}
const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
// ── State ──────────────────────────────────────────────────────────────────
let repoPath = "";
let activeRepoPath = "";
let activeView: AppView = "management";
let repoTabs: RepoTab[] = [];
let recentRepoPaths: string[] = [];
let repoSearch = "";
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 fileHistoryLoading = false;
let fileHistoryRequestId = 0;
let commitMessage = "";
let errorMessage = "";
let operation = "";
let compareFrom = "";
let compareTo = "";
let comparison: GitCommitComparison | null = null;
let newBranchCommit: GitCommit | null = null;
let renameBranchTarget: GitBranchInfo | null = null;
let compareSelectOpen = false;
let compareDialogOpen = false;
let selectedDiffPath = "";
let diffHighlightQuery = "";
let pendingRestoreFile: { commit: GitCommit; file: GitCommitFile } | null = null;
let linePatchOpen = false;
let linePatchFile: GitFileStatus | null = null;
let linePatchStaged = false;
let linePatchText = "";
let linePatchLoading = false;
let linePatchError = "";
let globalSearchOpen = false;
let lastSearchQuery = "";
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 credDialogKey: string | null = null;
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
let updateToastOpen = false;
let updateToastState: UpdateToastState = "available";
let pendingUpdate: Update | null = null;
let updateVersion = "";
let updateCurrentVersion = "";
let updateProgress = 0;
let updateError = "";
let updateCheckInFlight = false;
let updateDownloadTotal = 0;
let updateDownloadedBytes = 0;
// ── Derived ────────────────────────────────────────────────────────────────
$: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null;
$: workspaceActive = activeView === "repository" && hasRepository;
$: openingRepo = operation === "Opening repository";
$: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
$: 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);
$: repoSearchTerm = repoSearch.trim().toLowerCase();
$: openRepoRows = repoTabs.filter(repoMatchesSearch);
$: recentRepoRows = recentRepoPaths
.filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
.map(repoRowFromPath)
.filter(repoMatchesSearch);
$: allRepoRows = uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths])
.map(repoRowFromPath)
.filter(repoMatchesSearch);
// ── Lifecycle ──────────────────────────────────────────────────────────────
onMount(() => {
loadRepoLists();
autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
void checkForUpdates();
});
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 || activeView !== "repository" || !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);
// 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;
}
}
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) void autoRefreshTick();
}
// ── Updates ────────────────────────────────────────────────────────────────
async function checkForUpdates() {
if (updateCheckInFlight || updateToastState === "downloading") return;
updateCheckInFlight = true;
try {
const update = await check();
if (!update) return;
if (pendingUpdate && pendingUpdate !== update) {
void pendingUpdate.close().catch(() => {});
}
pendingUpdate = update;
updateVersion = update.version;
updateCurrentVersion = update.currentVersion;
updateProgress = 0;
updateError = "";
updateToastState = "available";
updateToastOpen = true;
} catch {
// Update checks should be quiet when offline or when the endpoint is unavailable.
} finally {
updateCheckInFlight = false;
}
}
function updateDownloadProgress(event: DownloadEvent) {
if (event.event === "Started") {
updateProgress = 0;
updateDownloadTotal = event.data.contentLength ?? 0;
updateDownloadedBytes = 0;
return;
}
if (event.event === "Progress") {
updateDownloadedBytes += event.data.chunkLength;
updateProgress = updateDownloadTotal > 0
? Math.min(99, Math.round((updateDownloadedBytes / updateDownloadTotal) * 100))
: 0;
return;
}
updateProgress = 100;
}
async function installPendingUpdate() {
if (!pendingUpdate || updateToastState === "downloading") return;
updateToastState = "downloading";
updateProgress = 0;
updateError = "";
updateToastOpen = true;
updateDownloadTotal = 0;
updateDownloadedBytes = 0;
try {
await pendingUpdate.downloadAndInstall(updateDownloadProgress);
updateProgress = 100;
updateToastState = "installed";
} catch (error) {
updateToastState = "error";
updateError = errorToMessage(error);
}
}
function dismissUpdateToast() {
if (updateToastState === "downloading") return;
updateToastOpen = false;
}
// ── Utilities ──────────────────────────────────────────────────────────────
function repoNameFromPath(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
}
function repoKey(path: string): string {
return path.replace(/\\/g, "/").trim().toLowerCase();
}
function sameRepoPath(left: string, right: string): boolean {
return repoKey(left) === repoKey(right);
}
function uniqueRepoPaths(paths: string[]): string[] {
const seen = new Set<string>();
const result: string[] = [];
for (const path of paths) {
const trimmed = path.trim();
if (!trimmed) continue;
const key = repoKey(trimmed);
if (seen.has(key)) continue;
seen.add(key);
result.push(trimmed);
}
return result;
}
function repoRowFromPath(path: string): RepoTab {
return repoTabs.find((tab) => sameRepoPath(tab.path, path)) ?? {
path,
name: repoNameFromPath(path),
branch: null,
ahead: 0,
behind: 0,
changed: 0,
lastOpened: 0,
};
}
function repoMatchesSearch(repo: RepoTab): boolean {
if (!repoSearchTerm) return true;
return repo.name.toLowerCase().includes(repoSearchTerm)
|| repo.path.toLowerCase().includes(repoSearchTerm)
|| (repo.branch ?? "").toLowerCase().includes(repoSearchTerm);
}
function loadRepoLists() {
try {
const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown;
const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown;
const openPaths = Array.isArray(openValue)
? openValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
: [];
const recentPaths = Array.isArray(recentValue)
? recentValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
: [];
repoTabs = uniqueRepoPaths(openPaths).map((path) => ({
path,
name: repoNameFromPath(path),
branch: null,
ahead: 0,
behind: 0,
changed: 0,
lastOpened: 0,
}));
recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]);
} catch {
repoTabs = [];
recentRepoPaths = [];
}
}
function persistRepoLists() {
try {
localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
} catch {
// Local storage is best-effort only; the Git workflow must keep working without it.
}
}
function rememberRecentRepo(path: string) {
recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
persistRepoLists();
}
function upsertRepoTab(path: string, nextStatus?: GitStatus | null) {
const existing = repoTabs.find((tab) => sameRepoPath(tab.path, path));
const next: RepoTab = {
path,
name: repoNameFromPath(path),
branch: nextStatus?.current_branch ?? existing?.branch ?? null,
ahead: nextStatus?.ahead ?? existing?.ahead ?? 0,
behind: nextStatus?.behind ?? existing?.behind ?? 0,
changed: nextStatus?.files.length ?? existing?.changed ?? 0,
lastOpened: Date.now(),
};
repoTabs = existing
? repoTabs.map((tab) => sameRepoPath(tab.path, path) ? next : tab)
: [...repoTabs, next];
rememberRecentRepo(path);
}
function resetRepositoryState(clearActive = false) {
if (clearActive) {
activeRepoPath = "";
repoPath = "";
status = null;
lastStatusFingerprint = "";
}
branches = [];
commits = [];
repoFiles = [];
selectedExplorerPath = "";
selectedExplorerKind = "file";
expandedExplorerPaths = new Set();
expandedCommitHashes = new Set();
fileHistory = [];
compareFrom = "";
compareTo = "";
comparison = null;
compareSelectOpen = false;
compareDialogOpen = false;
selectedDiffPath = "";
pendingRestoreFile = null;
newBranchCommit = null;
globalSearchResults = [];
globalSearchOpen = false;
globalSearchError = "";
resolveDialogOpen = false;
conflictTarget = "";
conflict = null;
preparedResolutions = {};
}
function applyStatus(nextStatus: GitStatus) {
status = nextStatus;
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
repoPath = activeRepoPath;
lastStatusFingerprint = statusFingerprint(nextStatus);
upsertRepoTab(activeRepoPath, 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"; }
}
function isNonFastForwardPushError(message: string): boolean {
const value = message.toLowerCase();
return value.includes("non-fast-forward")
|| value.includes("failed to push some refs")
|| value.includes("tip of your current branch is behind")
|| value.includes("fetch first");
}
function statusHasConflicts(value: GitStatus | null): boolean {
return (value?.files ?? []).some((file) => file.staged === "conflicted" || file.unstaged === "conflicted");
}
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 allExplorerFolderPaths(files: GitRepositoryFile[]): Set<string> {
const folders = new Set<string>();
for (const file of files) {
const parts = file.path.split(/[\\/]+/).filter(Boolean);
let currentPath = "";
for (let index = 0; index < parts.length - 1; index++) {
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index];
folders.add(currentPath);
}
}
return folders;
}
// ── Refresh helpers ────────────────────────────────────────────────────────
async function refreshBranchList(path = activeRepoPath, prefetched?: GitBranchInfo[]) {
branches = prefetched ?? (await listBranches(path));
}
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 = "";
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, 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)) {
selectedExplorerPath = "";
selectedExplorerKind = "file";
fileHistory = [];
}
}
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
const requestId = ++fileHistoryRequestId;
const history = file ? await listFileHistory(path, file, 100) : [];
if (requestId === fileHistoryRequestId) fileHistory = history;
}
// ── 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 () => {
// 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);
resetRepositoryState(false);
applyStatus(bundle.status);
if (globalSearchBusy) void cancelGlobalSearch();
activeView = "repository";
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
});
}
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);
}
}
function openRepoManagement() {
if (isBusy) return;
activeView = "management";
}
async function selectRepoTab(path: string) {
if (isBusy) return;
if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
await openRepo(path);
}
async function closeRepoTab(path: string, event?: MouseEvent) {
event?.stopPropagation();
if (isBusy) return;
const index = repoTabs.findIndex((tab) => sameRepoPath(tab.path, path));
const remaining = repoTabs.filter((tab) => !sameRepoPath(tab.path, path));
const next = remaining[index] ?? remaining[index - 1] ?? null;
repoTabs = remaining;
persistRepoLists();
if (!sameRepoPath(activeRepoPath, path)) return;
if (next) {
await openRepo(next.path);
} else {
resetRepositoryState(true);
activeView = "management";
}
}
async function removeRepoFromManagement(path: string, event?: MouseEvent) {
event?.stopPropagation();
if (isBusy) return;
recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
persistRepoLists();
if (repoTabs.some((tab) => sameRepoPath(tab.path, path))) {
await closeRepoTab(path);
}
}
async function openActiveRepoInExplorer() {
if (!activeRepoPath || isBusy) return;
try {
await openRepoInExplorer(activeRepoPath);
} 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 createNewBranch(branchName: string) {
const name = branchName.trim();
if (!activeRepoPath || !name) return;
await runOperation(`Creating ${name}`, async () => {
applyStatus(await createBranch(activeRepoPath, name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
function renameLocalBranch(branch: GitBranchInfo) {
if (!activeRepoPath || branch.remote) return;
renameBranchTarget = branch;
}
async function submitRenameBranch(branchName: string) {
const branch = renameBranchTarget;
const name = branchName.trim();
if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return;
await runOperation(`Renaming ${branch.name}`, async () => {
applyStatus(await renameBranch(activeRepoPath, branch.name, name));
renameBranchTarget = null;
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function deleteLocalBranch(branch: GitBranchInfo) {
if (!activeRepoPath || branch.remote) return;
if (branch.current) {
errorMessage = "The current branch cannot be deleted.";
return;
}
const confirmed = window.confirm(`Delete local branch "${branch.name}"?\n\nGit will refuse if the branch has unmerged changes.`);
if (!confirmed) return;
await runOperation(`Deleting ${branch.name}`, async () => {
applyStatus(await deleteBranch(activeRepoPath, branch.name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
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 () => {
applyStatus(await mergeBranch(activeRepoPath, branch.name));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
// Resolve the keychain key (host/org) for the active repo's remote.
async function currentCredKey(): Promise<string | null> {
if (!activeRepoPath) return null;
try {
const url = await getRemoteUrl(activeRepoPath);
return url ? orgKeyFromUrl(url) : null;
} catch {
return null;
}
}
async function loadStoredCredential(key: string | null): Promise<StoredCredential | null> {
if (!key) return null;
try {
return await credLoad(key);
} catch {
return null;
}
}
async function openCredentialDialog(action: "push" | "pull", key?: string | null) {
if (!activeRepoPath) return;
credDialogError = "";
credDialogAction = action;
credDialogKey = key === undefined ? await currentCredKey() : key;
credDialogOpen = true;
}
// Post-process a pull/push result: surface errors, and on rejected/expired
// credentials drop the stored entry and re-open the login dialog.
function handleRemoteResult(action: "push" | "pull", key: string | null, fromStore: boolean) {
if (!errorMessage) {
credDialogOpen = false;
credDialogAction = null;
return;
}
const auth = isAuthError(errorMessage);
const message = stripAuthPrefix(errorMessage);
errorMessage = "";
if (fromStore) {
if (auth) {
if (key) void credDelete(key).catch(() => {});
credDialogError =
"Zugangsdaten wurden abgelehnt oder sind abgelaufen. Bitte erneut anmelden.";
credDialogAction = action;
credDialogKey = key;
credDialogOpen = true;
} else {
// Non-auth failure (e.g. network) keep the stored credential, show it inline.
errorMessage = message;
}
} else {
credDialogError = message || "Anmeldung fehlgeschlagen.";
}
}
async function doActualPull(
username: string,
password: string,
key: string | null,
fromStore: boolean,
) {
errorMessage = "";
await runOperation("Pulling", async () => {
applyStatus(await pull(activeRepoPath, username, password));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
handleRemoteResult("pull", key, fromStore);
}
async function doActualPush(
username: string,
password: string,
key: string | null,
fromStore: boolean,
) {
errorMessage = "";
await runOperation("Pushing", async () => {
applyStatus(await push(activeRepoPath, username, password));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
if (errorMessage && !isAuthError(errorMessage) && isNonFastForwardPushError(errorMessage)) {
errorMessage = "";
const shouldSync = window.confirm(
"Der Remote hat neuere Commits, deshalb wurde der Push abgelehnt.\n\nJetzt Pull/Merge ausfuehren und danach den Push erneut versuchen?",
);
if (!shouldSync) {
const message = "Push abgelehnt: Der Remote hat neuere Commits. Pull zuerst ausfuehren, dann erneut pushen.";
if (fromStore) errorMessage = message;
else credDialogError = message;
return;
}
if (!fromStore) credDialogError = "";
await runOperation("Pulling before push", async () => {
applyStatus(await pull(activeRepoPath, username, password));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
if (errorMessage) {
handleRemoteResult("pull", key, fromStore);
return;
}
if (statusHasConflicts(status)) {
credDialogOpen = false;
credDialogAction = null;
errorMessage = "Pull hat Merge-Konflikte erzeugt. Loese die Konflikte, committe den Merge und pushe danach erneut.";
return;
}
await runOperation("Pushing after pull", async () => {
applyStatus(await push(activeRepoPath, username, password));
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
handleRemoteResult("push", key, fromStore);
}
async function handleCredentialSubmit(
username: string,
password: string,
save: boolean,
expiresAt: string | null,
) {
const key = credDialogKey;
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
// Only persist once the operation actually succeeded (dialog has closed).
if (!credDialogOpen && save && key) {
try {
await credSave(key, username, password, expiresAt);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
}
async function startRemoteAction(action: "push" | "pull") {
if (!activeRepoPath) return;
const key = await currentCredKey();
const stored = await loadStoredCredential(key);
if (stored && !isCredentialExpired(stored)) {
if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
else await doActualPush(stored.username, stored.password, key, true);
return;
}
// Expired entry → clean it up before prompting again.
if (stored && key) await credDelete(key).catch(() => {});
await openCredentialDialog(action, key);
}
async function pullRepo() {
await startRemoteAction("pull");
}
async function pushRepo() {
await startRemoteAction("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 openLinePatch(file: GitFileStatus, staged: boolean) {
if (!activeRepoPath) return;
linePatchOpen = true;
linePatchFile = file;
linePatchStaged = staged;
linePatchText = "";
linePatchError = "";
linePatchLoading = true;
try {
linePatchText = await getFilePatch(activeRepoPath, file.path, staged);
} catch (error) {
linePatchError = errorToMessage(error);
errorMessage = linePatchError;
} finally {
linePatchLoading = false;
}
}
async function refreshLinePatch() {
if (!activeRepoPath || !linePatchFile) return;
await openLinePatch(linePatchFile, linePatchStaged);
}
function closeLinePatch() {
if (isBusy) return;
linePatchOpen = false;
linePatchFile = null;
linePatchText = "";
linePatchError = "";
}
function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string {
switch (action) {
case "stage":
return `Staging hunk in ${file.path}`;
case "unstage":
return `Unstaging hunk in ${file.path}`;
default:
return `Discarding hunk in ${file.path}`;
}
}
async function applyLinePatch(action: PatchApplyAction, patch: string) {
if (!activeRepoPath || !linePatchFile || isBusy) return;
const file = linePatchFile;
operation = patchOperationLabel(action, file);
errorMessage = "";
linePatchError = "";
try {
applyStatus(await applyFilePatch(activeRepoPath, file.path, patch, action));
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
const updatedPatch = await getFilePatch(activeRepoPath, file.path, linePatchStaged);
if (updatedPatch.trim()) {
linePatchText = updatedPatch;
} else {
linePatchOpen = false;
linePatchFile = null;
linePatchText = "";
}
} catch (error) {
linePatchError = errorToMessage(error);
errorMessage = linePatchError;
} finally {
operation = "";
}
}
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(`Restore working tree to ${target.short_hash}?\n\nThis brings back the files from that commit as unstaged changes so you can review and commit them. No commit is removed and the branch stays where it is.`);
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 compareFileToParent(activeRepoPath, target.hash, file.path, file.old_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;
}
function expandAllExplorerFolders() {
expandedExplorerPaths = allExplorerFolderPaths(repoFiles);
}
function collapseAllExplorerFolders() {
expandedExplorerPaths = new Set();
}
function explorerParentFolders(path: string): string[] {
const parts = normalizeExplorerPath(path).split("/").filter(Boolean);
const folders: string[] = [];
let current = "";
for (let index = 0; index < parts.length - 1; index++) {
current = current ? `${current}/${parts[index]}` : parts[index];
folders.push(current);
}
return folders;
}
// Loads history for a selected explorer node without blocking the rest of the UI
// (isBusy/runOperation would disable every button in the app while this awaits).
// A request id guards against a slower, stale request overwriting a newer selection.
async function loadSelectedFileHistory(path: string, repo = activeRepoPath) {
const requestId = ++fileHistoryRequestId;
fileHistoryLoading = true;
try {
const history = await listFileHistory(repo, path, 100);
if (requestId === fileHistoryRequestId) fileHistory = history;
} catch (error) {
if (requestId === fileHistoryRequestId) {
fileHistory = [];
errorMessage = errorToMessage(error);
}
} finally {
if (requestId === fileHistoryRequestId) fileHistoryLoading = false;
}
}
async function selectExplorerNode(node: ExplorerNode) {
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
selectedExplorerPath = node.path;
selectedExplorerKind = node.kind;
await loadSelectedFileHistory(node.path);
}
async function selectFileFromSearch(file: GitRepositoryFile) {
if (!activeRepoPath) return;
selectedExplorerPath = file.path;
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
await loadSelectedFileHistory(file.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 ────────────────────────────────────────────────────────────────
function openCompareSelect() {
if (!hasRepository) return;
compareSelectOpen = true;
}
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 ?? "";
diffHighlightQuery = "";
pendingRestoreFile = null;
compareSelectOpen = false;
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;
diffHighlightQuery = "";
pendingRestoreFile = null;
compareDialogOpen = true;
});
}
async function diffSearchHit(hit: GitSearchHit) {
if (!activeRepoPath) return;
await runOperation(`Diffing ${hit.file}`, async () => {
const result = await diffFileAgainstWorkingTree(activeRepoPath, hit.commit_hash, hit.file);
comparison = result;
selectedDiffPath = result.files[0]?.path ?? hit.file;
diffHighlightQuery = lastSearchQuery;
pendingRestoreFile = null;
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;
lastSearchQuery = query;
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 handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
}
function handleWindowContextMenu(event: MouseEvent) {
event.preventDefault();
}
</script>
<svelte:head>
<title>GitLite</title>
</svelte:head>
<svelte:window on:keydown={handleWindowKeydown} on:contextmenu={handleWindowContextMenu} />
<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={workspaceActive}
{isBusy}
{operation}
{autoRefreshEnabled}
{autoRefreshInFlight}
onPull={pullRepo}
onPush={pushRepo}
onRefresh={refreshRepo}
onSearch={() => { globalSearchOpen = true; }}
onCompare={openCompareSelect}
onOpenInExplorer={openActiveRepoInExplorer}
onToggleAutoRefresh={toggleAutoRefresh}
/>
<div class="shell-body">
<header class="repo-tabbar" aria-label="Repository tabs">
<button
class="repo-tab management"
class:active={activeView === "management"}
type="button"
onclick={openRepoManagement}
disabled={isBusy}
title="Repository Management"
>
<BookOpen size={14} aria-hidden="true" />
Repository Management
</button>
<div class="repo-tabs-scroll">
{#each repoTabs as repo (repo.path)}
<div class="repo-tab-wrap" class:active={activeView === "repository" && sameRepoPath(activeRepoPath, repo.path)}>
<button
class="repo-tab"
type="button"
onclick={() => selectRepoTab(repo.path)}
disabled={isBusy}
title={repo.path}
>
<FolderOpen size={14} aria-hidden="true" />
<span>{repo.name}</span>
{#if repo.branch}
<strong>{repo.branch}</strong>
{/if}
</button>
<button
class="repo-tab-close"
type="button"
onclick={(event) => closeRepoTab(repo.path, event)}
disabled={isBusy}
aria-label={`Close ${repo.name}`}
title="Close repository tab"
>
<X size={13} aria-hidden="true" />
</button>
</div>
{/each}
</div>
<button
class="repo-tab-add"
type="button"
onclick={chooseRepositoryFolder}
disabled={isBusy}
title="Open repository folder"
aria-label="Open repository folder"
>
<Plus size={15} aria-hidden="true" />
</button>
</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 workspaceActive && 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}
{#if activeView === "management"}
<section class="repo-management" aria-label="Repository Management">
<div class="repo-management-head">
<div>
<span class="eyebrow">Repository Management</span>
<h1>Repositories</h1>
</div>
<div class="repo-management-actions">
<button class="btn-secondary" type="button" onclick={chooseRepositoryFolder} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" />
Browse
</button>
</div>
</div>
<div class="repo-management-tools">
<div class="repo-search">
<Search size={15} aria-hidden="true" />
<input
bind:value={repoSearch}
autocomplete="off"
spellcheck="false"
placeholder="Search repositories"
aria-label="Search repositories"
/>
</div>
</div>
<div class="repo-sections">
<section class="repo-section">
<header>
<h2>Open repositories</h2>
<span>{openRepoRows.length}</span>
</header>
{#if openRepoRows.length === 0}
<div class="repo-empty">No open repositories.</div>
{:else}
<div class="repo-table">
{#each openRepoRows as repo (repo.path)}
<div class="repo-row">
<button class="repo-row-main" type="button" onclick={() => selectRepoTab(repo.path)} disabled={isBusy}>
<span class="repo-row-name">{repo.name}</span>
<span class="repo-row-path">{repo.path}</span>
<span class="repo-row-meta">
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{/if}
{#if repo.ahead > 0}<em class="ahead">{repo.ahead}</em>{/if}
{#if repo.behind > 0}<em class="behind">{repo.behind}</em>{/if}
{#if repo.changed > 0}<em>{repo.changed} changed</em>{/if}
</span>
</button>
<button class="repo-row-icon" type="button" onclick={(event) => closeRepoTab(repo.path, event)} disabled={isBusy} title="Close tab" aria-label={`Close ${repo.name}`}>
<X size={14} aria-hidden="true" />
</button>
</div>
{/each}
</div>
{/if}
</section>
<section class="repo-section">
<header>
<h2>Recent repositories</h2>
<span>{recentRepoRows.length}</span>
</header>
{#if recentRepoRows.length === 0}
<div class="repo-empty">No recent repositories.</div>
{:else}
<div class="repo-table">
{#each recentRepoRows as repo (repo.path)}
<div class="repo-row">
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
<span class="repo-row-name">{repo.name}</span>
<span class="repo-row-path">{repo.path}</span>
<span class="repo-row-meta quiet">recent</span>
</button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove from recent" aria-label={`Remove ${repo.name}`}>
<X size={14} aria-hidden="true" />
</button>
</div>
{/each}
</div>
{/if}
</section>
<section class="repo-section">
<header>
<h2>All repositories</h2>
<span>{allRepoRows.length}</span>
</header>
{#if allRepoRows.length === 0}
<div class="repo-empty">Browse for a repository to add it here.</div>
{:else}
<div class="repo-table">
{#each allRepoRows as repo (repo.path)}
<div class="repo-row">
<button class="repo-row-main" type="button" onclick={() => openRepo(repo.path)} disabled={isBusy}>
<span class="repo-row-name">{repo.name}</span>
<span class="repo-row-path">{repo.path}</span>
<span class="repo-row-meta">
{#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em>known repo</em>{/if}
</span>
</button>
<button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromManagement(repo.path, event)} disabled={isBusy} title="Remove" aria-label={`Remove ${repo.name}`}>
<X size={14} aria-hidden="true" />
</button>
</div>
{/each}
</div>
{/if}
</section>
</div>
</section>
{:else}
<!-- 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}
onCreateBranch={createNewBranch}
onRenameBranch={renameLocalBranch}
onDeleteBranch={deleteLocalBranch}
/>
<ExplorerPanel
{repoFiles}
{expandedExplorerPaths}
{selectedExplorerPath}
{selectedExplorerKind}
{hasRepository}
{isBusy}
onToggleFolder={toggleExplorerFolder}
onExpandAllFolders={expandAllExplorerFolders}
onCollapseAllFolders={collapseAllExplorerFolders}
onSelectNode={selectExplorerNode}
/>
</aside>
<!-- Center: summary + status + commit -->
<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}
onPatch={openLinePatch}
onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles}
/>
<CommitPanel
{commitMessage}
{canCommit}
{commitBlockReason}
{hasRepository}
{isBusy}
{operation}
{stagedCount}
onCommit={commitChanges}
onCommitMessageChange={(msg) => { commitMessage = msg; }}
/>
</div>
</section>
<!-- Right sidebar: commit graph + file history -->
<aside class="history-aside" aria-label="Commit history">
<HistoryPanel
{commits}
{hasRepository}
{isBusy}
{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);
expandedCommitHashes = next;
}}
/>
<FileHistoryPanel
{fileHistory}
{selectedExplorerPath}
selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"}
{hasRepository}
{isBusy}
isLoading={fileHistoryLoading}
onDiff={diffSelectedFileFromCommit}
onRestore={restoreSelectedFileFromCommit}
/>
</aside>
</section>
{/if}
</div>
</main>
{#if updateToastOpen}
<UpdateToast
state={updateToastState}
version={updateVersion}
currentVersion={updateCurrentVersion}
progress={updateProgress}
error={updateError}
onInstall={installPendingUpdate}
onLater={dismissUpdateToast}
onDismiss={dismissUpdateToast}
/>
{/if}
{#if linePatchOpen && linePatchFile}
<LinePatchDialog
file={linePatchFile}
staged={linePatchStaged}
patch={linePatchText}
{isBusy}
isLoading={linePatchLoading}
error={linePatchError}
onClose={closeLinePatch}
onRefresh={refreshLinePatch}
onApply={applyLinePatch}
/>
{/if}
{#if globalSearchOpen}
<GlobalSearchDialog
{hasRepository}
{isBusy}
isSearching={globalSearchBusy}
error={globalSearchError}
results={globalSearchResults}
files={repoFiles}
fileHistory={fileHistory}
selectedFilePath={selectedExplorerPath}
onClose={closeGlobalSearchDialog}
onSearch={runGlobalSearch}
onCancel={cancelGlobalSearch}
onDiff={diffSearchHit}
onSelectFile={selectFileFromSearch}
onFileHistoryDiff={diffSelectedFileFromCommit}
onFileHistoryRestore={restoreSelectedFileFromCommit}
/>
{/if}
<!-- Create a branch from a specific commit in the history -->
{#if newBranchCommit}
<NewBranchDialog
commit={newBranchCommit}
{isBusy}
onCreate={createBranchFromCommit}
onClose={() => { newBranchCommit = null; }}
/>
{/if}
<!-- Rename a local branch from the branch context menu -->
{#if renameBranchTarget}
<RenameBranchDialog
branch={renameBranchTarget}
{isBusy}
onRename={submitRenameBranch}
onClose={() => { renameBranchTarget = 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
{comparison}
{selectedDiffPath}
{isBusy}
highlightQuery={diffHighlightQuery}
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
onClose={closeCompareDialog}
onRestore={restorePreviewedCommitFile}
onSelectFile={selectDiffFile}
/>
{/if}
<!-- Credential dialog for push/pull -->
{#if credDialogOpen && credDialogAction}
<CredentialDialog
action={credDialogAction}
error={credDialogError}
{isBusy}
onSubmit={handleCredentialSubmit}
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }}
/>
{/if}
<!-- Full-screen overlay while a repository is being opened -->
{#if openingRepo}
<RepoLoadingOverlay repoName={repoDisplayName} />
{/if}
<!-- Conflict resolve dialog -->
{#if resolveDialogOpen}
<ResolveDialog
{conflictedFiles}
{conflictTarget}
{conflict}
{preparedResolutions}
{isBusy}
{operation}
onClose={() => { resolveDialogOpen = false; }}
onSelectFile={selectConflictFile}
onMarkResolved={handleMarkResolved}
onApply={applyPreparedResolutions}
/>
{/if}