1802 lines
64 KiB
Svelte
1802 lines
64 KiB
Svelte
<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}
|