Files
GitLite/src/App.svelte
T
Christoph Brandau 1d67312ee4 feat(git stash): add stash listing and push/apply/pop/drop UI
This change introduces Git stash support end-to-end, including a new
backend command to list stashes and operations to push, apply, pop, and
drop them. The frontend now fetches stashes as part of the repository
bundle and provides a dedicated panel to manage shelved changes.

- Add GitStash model and Tauri commands for stash operations
- Implement StashPanel component and wire it into the app
- Adjust sidebar layout and add stash-specific styling
2026-07-04 00:16:27 +02:00

2378 lines
84 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 AiSettingsDialog from "./lib/components/AiSettingsDialog.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 DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.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 StashPanel from "./lib/components/StashPanel.svelte";
import StatusPanel from "./lib/components/StatusPanel.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte";
import {
checkoutBranch,
commit,
commitAiGenerate,
commitAiLoad,
commitAiLocalModels,
commitAiStatus,
compareCommits,
cancelCodeSearch,
cancelFileHistory,
applyFilePatch,
createBranch,
deleteBranch,
diffFileAgainstWorkingTree,
compareFileToParent,
fetchRemote,
getStatus,
listBranches,
listStashes,
listCommits,
listFileHistory,
listRepositoryFiles,
mergeBranch,
openRepoInExplorer,
openRepositoryFile,
openRepositoryBundle,
pull,
push,
renameBranch,
getRemoteUrl,
credLoad,
credSave,
credDelete,
getFilePatch,
readConflict,
resolveConflict,
resolveConflictSide,
restoreFileFromCommit,
restoreFiles,
restoreToCommit,
searchCodeIntroductions,
setSyncBadge,
stageFiles,
stashApply,
stashDrop,
stashPop,
stashPush,
unstageFiles,
} from "./lib/git";
import type {
AiSettings,
CommitAiPhase,
ConflictFile,
ExplorerNode,
ExplorerNodeKind,
GitBranch as GitBranchInfo,
GitCommit,
GitCommitFile,
GitCommitComparison,
GitDiffFile,
GitFileStatus,
GitRepositoryFile,
GitSearchHit,
GitStash,
GitStatus,
LocalModelOption,
PatchApplyAction,
PreparedResolution,
StoredCredential,
} from "./lib/types";
import {
orgKeyFromUrl,
isCredentialExpired,
isAuthError,
stripAuthPrefix,
} from "./lib/credentials";
type UpdateToastState = "available" | "downloading" | "installed" | "error";
type AppView = "management" | "repository";
type PendingDiscard =
| { kind: "file"; file: GitFileStatus; staged: boolean }
| { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string };
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";
const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
const COMMIT_PANEL_MAX_HEIGHT = 640;
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
const HISTORY_ASIDE_MIN_WIDTH = 560;
const HISTORY_ASIDE_MAX_WIDTH = 920;
// ── 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 stashes: GitStash[] = [];
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 activeFileHistoryRequestId = "";
let lastFileHistoryHeadHash = "";
let commitMessage = "";
let lastLocalAiGeneratedMessage = "";
let commitAiPhase: CommitAiPhase = "idle";
let commitAiGenerating = false;
let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
let aiSettings: AiSettings = defaultAiSettings();
let aiSettingsOpen = false;
let localModelOptions: LocalModelOption[] = [];
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 pendingDiscard: PendingDiscard | null = null;
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" | "fetch" | null = null;
let credDialogError = "";
let credDialogKey: string | null = null;
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
const BACKGROUND_FETCH_INTERVAL = 180_000;
const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000;
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
let backgroundFetchInFlight = false;
let lastRepoSwitchAt = 0;
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;
let commitPanelHeight = loadCommitPanelHeight();
let resizingCommitPanel = false;
let resizeStartY = 0;
let resizeStartHeight = 0;
let historyAsideWidth = loadHistoryAsideWidth();
let resizingHistoryAside = false;
let historyResizeStartX = 0;
let historyResizeStartWidth = 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);
$: localBranchNames = localBranches.map((b) => b.name);
$: 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);
backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL);
void checkForUpdates();
void initCommitAi();
});
onDestroy(() => {
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
});
// ── 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 });
}
// Silent background fetch (every 180s): only updates the local remote-tracking ref so
// ahead/behind (and the taskbar badge) stay accurate without the user pulling manually.
// Errors are swallowed here — auth/network failures surface via the manual Fetch/Pull/
// Push buttons instead, not as a background popup.
async function backgroundFetchTick() {
if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || backgroundFetchInFlight) return;
if (Date.now() - lastRepoSwitchAt < BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) return;
backgroundFetchInFlight = true;
try {
await fetchRemote(activeRepoPath);
} catch {
// ignore — see comment above
} finally {
backgroundFetchInFlight = false;
}
}
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);
const previousHeadHash = lastFileHistoryHeadHash;
await refreshBranchList(activeRepoPath, bundle.branches);
await refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
// File history reflects `git log`, which only changes when HEAD actually moves
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
// working-tree/status change (staging, edits) doesn't keep re-fetching and
// flickering the currently viewed file's history.
if (lastFileHistoryHeadHash !== previousHeadHash) {
await refreshFileHistory(activeRepoPath);
}
} catch { /* ignore transient errors */ } finally {
autoRefreshInFlight = false;
}
}
// ── Commit AI ──────────────────────────────────────────────────────────────
function stopCommitAiPolling() {
if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
}
async function pollCommitAiStatus() {
try {
const result = await commitAiStatus();
commitAiPhase = result.phase;
} catch { /* ignore transient errors */ }
if (commitAiPhase === "ready" || commitAiPhase === "error") stopCommitAiPolling();
}
function startCommitAiPolling() {
// Only the local model has a download/load phase worth polling — cloud providers are
// plain API calls with nothing to wait for.
stopCommitAiPolling();
if (aiSettings.provider !== "local") return;
void pollCommitAiStatus();
commitAiPollTimer = setInterval(() => { void pollCommitAiStatus(); }, 2000);
}
async function initCommitAi() {
aiSettings = loadAiSettings();
try {
localModelOptions = await commitAiLocalModels();
} catch { /* AI features stay disabled if this fails; not fatal to the app */ }
if (aiSettings.provider === "local") {
try { await commitAiLoad(aiSettings.localModelId); } catch { /* surfaced via status polling */ }
}
startCommitAiPolling();
}
function saveAiSettings(next: AiSettings) {
const modelChanged = next.provider === "local" && next.localModelId !== aiSettings.localModelId;
aiSettings = next;
persistAiSettings(next);
aiSettingsOpen = false;
if (next.provider === "local" && (modelChanged || commitAiPhase === "idle")) {
commitAiPhase = "idle";
void commitAiLoad(next.localModelId);
}
startCommitAiPolling();
}
function updateCommitMessage(message: string) {
commitMessage = message;
if (message !== lastLocalAiGeneratedMessage) {
lastLocalAiGeneratedMessage = "";
}
}
async function generateCommitMessageWithAi() {
if (!activeRepoPath || commitAiGenerating) return;
if (aiSettings.provider === "local" && commitAiPhase !== "ready") return;
commitAiGenerating = true;
errorMessage = "";
try {
const notes = commitMessage.trim() || undefined;
if (aiSettings.provider === "local") {
const localNotes = notes && notes !== lastLocalAiGeneratedMessage ? notes : undefined;
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "local",
notes: localNotes,
localProfile: aiSettings.localProfile,
});
lastLocalAiGeneratedMessage = commitMessage;
} else if (aiSettings.provider === "openai") {
const cred = await credLoad("ai:openai");
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "openai",
notes,
model: aiSettings.openaiModel,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
} else if (aiSettings.provider === "anthropic") {
const cred = await credLoad("ai:anthropic");
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "anthropic",
notes,
model: aiSettings.anthropicModel,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
} else {
const cred = await credLoad("ai:custom");
commitMessage = await commitAiGenerate(activeRepoPath, {
provider: "custom",
notes,
model: aiSettings.customModel,
baseUrl: aiSettings.customBaseUrl,
apiKey: cred?.password,
});
lastLocalAiGeneratedMessage = "";
}
} catch (error) {
errorMessage = errorToMessage(error);
} finally {
commitAiGenerating = 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 defaultAiSettings(): AiSettings {
return {
provider: "openai",
localModelId: "qwen2.5-0.5b",
localProfile: "fast",
openaiModel: "gpt-4o-mini",
anthropicModel: "claude-3-5-haiku-latest",
customBaseUrl: "",
customModel: "",
};
}
function loadAiSettings(): AiSettings {
try {
const stored = JSON.parse(localStorage.getItem(AI_SETTINGS_KEY) ?? "null") as unknown;
if (stored && typeof stored === "object") {
const merged = { ...defaultAiSettings(), ...(stored as Partial<AiSettings>) };
// Local AI is still in development and disabled in the settings UI — migrate any
// previously saved selection away from it so nobody gets stuck on a dead option.
if (merged.provider === "local") merged.provider = "openai";
return merged;
}
} catch {
// Fall through to defaults below.
}
return defaultAiSettings();
}
function persistAiSettings(next: AiSettings) {
try {
localStorage.setItem(AI_SETTINGS_KEY, JSON.stringify(next));
} catch {
// Local storage is best-effort only; AI generation must keep working without it.
}
}
function clampCommitPanelHeight(value: number): number {
return Math.min(COMMIT_PANEL_MAX_HEIGHT, Math.max(COMMIT_PANEL_MIN_HEIGHT, Math.round(value)));
}
function loadCommitPanelHeight(): number {
try {
const stored = Number(localStorage.getItem(COMMIT_PANEL_HEIGHT_KEY));
if (Number.isFinite(stored) && stored > 0) return clampCommitPanelHeight(stored);
} catch {
// Fall through to the default below.
}
return COMMIT_PANEL_DEFAULT_HEIGHT;
}
function persistCommitPanelHeight(value: number) {
try {
localStorage.setItem(COMMIT_PANEL_HEIGHT_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function clampHistoryAsideWidth(value: number): number {
return Math.min(HISTORY_ASIDE_MAX_WIDTH, Math.max(HISTORY_ASIDE_MIN_WIDTH, Math.round(value)));
}
function loadHistoryAsideWidth(): number {
try {
const stored = Number(localStorage.getItem(HISTORY_ASIDE_WIDTH_KEY));
if (Number.isFinite(stored) && stored > 0) return clampHistoryAsideWidth(stored);
} catch {
// Fall through to the default below.
}
return HISTORY_ASIDE_DEFAULT_WIDTH;
}
function persistHistoryAsideWidth(value: number) {
try {
localStorage.setItem(HISTORY_ASIDE_WIDTH_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function startCommitPanelResize(event: PointerEvent) {
event.preventDefault();
resizingCommitPanel = true;
resizeStartY = event.clientY;
resizeStartHeight = commitPanelHeight;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onCommitPanelResizeMove(event: PointerEvent) {
if (!resizingCommitPanel) return;
commitPanelHeight = clampCommitPanelHeight(resizeStartHeight + (resizeStartY - event.clientY));
}
function endCommitPanelResize(event: PointerEvent) {
if (!resizingCommitPanel) return;
resizingCommitPanel = false;
persistCommitPanelHeight(commitPanelHeight);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onCommitPanelResizeKeydown(event: KeyboardEvent) {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
commitPanelHeight = clampCommitPanelHeight(commitPanelHeight + (event.key === "ArrowUp" ? 20 : -20));
persistCommitPanelHeight(commitPanelHeight);
}
function startHistoryAsideResize(event: PointerEvent) {
event.preventDefault();
resizingHistoryAside = true;
historyResizeStartX = event.clientX;
historyResizeStartWidth = historyAsideWidth;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onHistoryAsideResizeMove(event: PointerEvent) {
if (!resizingHistoryAside) return;
historyAsideWidth = clampHistoryAsideWidth(historyResizeStartWidth + (historyResizeStartX - event.clientX));
}
function endHistoryAsideResize(event: PointerEvent) {
if (!resizingHistoryAside) return;
resizingHistoryAside = false;
persistHistoryAsideWidth(historyAsideWidth);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onHistoryAsideResizeKeydown(event: KeyboardEvent) {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
historyAsideWidth = clampHistoryAsideWidth(historyAsideWidth + (event.key === "ArrowLeft" ? 24 : -24));
persistHistoryAsideWidth(historyAsideWidth);
}
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 = "";
void setSyncBadge(0, 0, 0).catch(() => {});
}
branches = [];
stashes = [];
commits = [];
lastFileHistoryHeadHash = "";
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);
void setSyncBadge(nextStatus.ahead, nextStatus.behind, nextStatus.files.length).catch(() => {});
}
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 refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
stashes = prefetched ?? (await listStashes(path));
}
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
commits = prefetched ?? (await listCommits(path, 100));
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
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";
cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistory = [];
}
}
function isCancellationMessage(message: string): boolean {
return message.toLowerCase().includes("cancelled");
}
function cancelActiveFileHistoryLoad() {
const requestId = activeFileHistoryRequestId;
if (!requestId) return;
void cancelFileHistory(requestId).catch(() => {});
}
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
const requestId = ++fileHistoryRequestId;
cancelActiveFileHistoryLoad();
if (!path || !file) {
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistory = [];
return;
}
const historyRequestId = `file-history-${requestId}-${Date.now()}`;
activeFileHistoryRequestId = historyRequestId;
fileHistoryLoading = true;
fileHistory = [];
try {
const history = await listFileHistory(path, file, 100, historyRequestId);
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
fileHistory = history;
}
} catch (error) {
const message = errorToMessage(error);
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
fileHistory = [];
if (!isCancellationMessage(message)) errorMessage = message;
}
} finally {
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
}
}
}
// ── 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 refreshStashes(activeRepoPath, bundle.stashes);
await refreshCommitHistory(activeRepoPath, bundle.commits);
await refreshExplorerFiles(activeRepoPath, bundle.files);
lastRepoSwitchAt = Date.now();
});
}
async function chooseRepositoryFolder() {
if (isBusy) return;
try {
const selected = await openDialog({
title: "Select repository folder",
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 refreshStashes(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" | "fetch", 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" | "fetch", 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 =
"Credentials were rejected or have expired. Please sign in again.";
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 || "Sign-in failed.";
}
}
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 doActualFetch(
username: string,
password: string,
key: string | null,
fromStore: boolean,
) {
errorMessage = "";
await runOperation("Fetching", async () => {
applyStatus(await fetchRemote(activeRepoPath, username, password));
});
handleRemoteResult("fetch", 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(
"The remote has newer commits, so the push was rejected.\n\nRun Pull/Merge now and try pushing again afterwards?",
);
if (!shouldSync) {
const message = "Push rejected: the remote has newer commits. Pull first, then push again.";
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 produced merge conflicts. Resolve the conflicts, commit the merge, and then push again.";
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);
else if (credDialogAction === "fetch") await doActualFetch(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" | "fetch") {
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 if (action === "fetch") await doActualFetch(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 fetchRepo() {
await startRemoteAction("fetch");
}
async function pullRepo() {
await startRemoteAction("pull");
}
async function pushRepo() {
await startRemoteAction("push");
}
async function saveStash(message: string, includeUntracked: boolean) {
if (!activeRepoPath || changedFiles.length === 0) return;
await runOperation("Stashing changes", async () => {
applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function applyStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
await runOperation(`Applying ${stash.selector}`, async () => {
applyStatus(await stashApply(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function popStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
await runOperation(`Popping ${stash.selector}`, async () => {
applyStatus(await stashPop(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
});
}
async function dropStashEntry(stash: GitStash) {
if (!activeRepoPath) return;
const confirmed = window.confirm(`Delete ${stash.selector}?\n\n"${stash.message || stash.selector}"`);
if (!confirmed) return;
await runOperation(`Dropping ${stash.selector}`, async () => {
applyStatus(await stashDrop(activeRepoPath, stash.selector));
await refreshStashes(activeRepoPath);
});
}
// ── 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);
});
}
function discardFile(file: GitFileStatus, staged: boolean) {
if (!activeRepoPath || isBusy) return;
pendingDiscard = { kind: "file", file, staged };
}
async function runDiscardFile(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}`;
}
}
function isDiscardPatchAction(action: PatchApplyAction): boolean {
return action === "discard-staged" || action === "discard-unstaged";
}
async function runLinePatchAction(
action: PatchApplyAction,
patch: string,
file: GitFileStatus,
staged: boolean,
) {
if (!activeRepoPath || isBusy) return;
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, staged);
if (updatedPatch.trim()) {
linePatchText = updatedPatch;
} else {
linePatchOpen = false;
linePatchFile = null;
linePatchText = "";
}
} catch (error) {
linePatchError = errorToMessage(error);
errorMessage = linePatchError;
} finally {
operation = "";
}
}
async function applyLinePatch(action: PatchApplyAction, patch: string) {
if (!activeRepoPath || !linePatchFile || isBusy) return;
const file = linePatchFile;
const staged = linePatchStaged;
if (isDiscardPatchAction(action)) {
pendingDiscard = { kind: "hunk", file, staged, action, patch };
return;
}
await runLinePatchAction(action, patch, file, staged);
}
async function confirmDiscard() {
const discard = pendingDiscard;
if (!discard || !activeRepoPath || isBusy) return;
if (discard.kind === "file") {
await runDiscardFile(discard.file, discard.staged);
} else {
await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged);
}
pendingDiscard = null;
}
function closeDiscardConfirm() {
if (isBusy) return;
pendingDiscard = null;
}
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 = "";
lastLocalAiGeneratedMessage = "";
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) {
await refreshFileHistory(repo, path);
}
async function selectExplorerNode(node: ExplorerNode) {
if (!activeRepoPath || (selectedExplorerPath === node.path && selectedExplorerKind === node.kind)) return;
selectedExplorerPath = node.path;
selectedExplorerKind = node.kind;
void loadSelectedFileHistory(node.path);
}
async function selectFileFromSearch(file: GitRepositoryFile) {
if (!activeRepoPath) return;
selectedExplorerPath = file.path;
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
void loadSelectedFileHistory(file.path);
}
function selectFileFromStatus(file: GitFileStatus) {
if (!activeRepoPath) return;
selectedExplorerPath = file.path;
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
void loadSelectedFileHistory(file.path);
}
async function openFileFromExplorer(node: ExplorerNode) {
if (!activeRepoPath || node.kind !== "file") return;
selectedExplorerPath = node.path;
selectedExplorerKind = "file";
void loadSelectedFileHistory(node.path);
try {
await openRepositoryFile(activeRepoPath, node.path);
} catch (error) {
errorMessage = errorToMessage(error);
}
}
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("cancelled") ? "Search was cancelled." : message;
}
} finally {
if (globalSearchId === searchId) {
globalSearchBusy = false;
globalSearchId = "";
}
}
}
async function cancelGlobalSearch() {
if (!globalSearchId) return;
const searchId = globalSearchId;
globalSearchError = "Requesting cancellation...";
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" && pendingDiscard && !isBusy) closeDiscardConfirm();
else 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}
onFetch={fetchRepo}
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" style="--history-aside-width: {historyAsideWidth}px;">
<!-- 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}
/>
<StashPanel
{stashes}
changedCount={changedFiles.length}
{hasRepository}
{isBusy}
onPush={saveStash}
onApply={applyStashEntry}
onPop={popStashEntry}
onDrop={dropStashEntry}
/>
<ExplorerPanel
{repoFiles}
{expandedExplorerPaths}
{selectedExplorerPath}
{selectedExplorerKind}
{hasRepository}
{isBusy}
onToggleFolder={toggleExplorerFolder}
onExpandAllFolders={expandAllExplorerFolders}
onCollapseAllFolders={collapseAllExplorerFolders}
onSelectNode={selectExplorerNode}
onOpenFile={openFileFromExplorer}
/>
</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" style="--commit-panel-height: {commitPanelHeight}px;">
<StatusPanel
{changedFiles}
{stagedCount}
{unstagedCount}
{hasRepository}
{isBusy}
{status}
selectedFilePath={selectedExplorerPath}
onSelectFile={selectFileFromStatus}
onStage={stageFile}
onUnstage={unstageFile}
onDiscard={discardFile}
onPatch={openLinePatch}
onStageAll={stageAllFiles}
onUnstageAll={unstageAllFiles}
/>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="panel-resize-handle"
class:resizing={resizingCommitPanel}
role="separator"
aria-orientation="horizontal"
aria-label="Resize commit panel height"
aria-valuenow={commitPanelHeight}
aria-valuemin={COMMIT_PANEL_MIN_HEIGHT}
aria-valuemax={COMMIT_PANEL_MAX_HEIGHT}
tabindex="0"
onpointerdown={startCommitPanelResize}
onpointermove={onCommitPanelResizeMove}
onpointerup={endCommitPanelResize}
onpointercancel={endCommitPanelResize}
onkeydown={onCommitPanelResizeKeydown}
></div>
<CommitPanel
{commitMessage}
{canCommit}
{commitBlockReason}
{hasRepository}
{isBusy}
{operation}
{stagedCount}
commitAiProvider={aiSettings.provider}
{commitAiPhase}
{commitAiGenerating}
onCommit={commitChanges}
onCommitMessageChange={updateCommitMessage}
onGenerateCommitMessage={generateCommitMessageWithAi}
onOpenAiSettings={() => { aiSettingsOpen = true; }}
/>
</div>
</section>
<!-- Right sidebar: commit graph + file history -->
<aside class="history-aside" aria-label="Commit history">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="history-resize-handle"
class:resizing={resizingHistoryAside}
role="separator"
aria-orientation="vertical"
aria-label="Resize history panel width"
aria-valuenow={historyAsideWidth}
aria-valuemin={HISTORY_ASIDE_MIN_WIDTH}
aria-valuemax={HISTORY_ASIDE_MAX_WIDTH}
tabindex="0"
onpointerdown={startHistoryAsideResize}
onpointermove={onHistoryAsideResizeMove}
onpointerup={endHistoryAsideResize}
onpointercancel={endHistoryAsideResize}
onkeydown={onHistoryAsideResizeKeydown}
></div>
<HistoryPanel
{commits}
{localBranchNames}
activeBranch={status?.current_branch ?? ""}
repositoryKey={activeRepoPath}
{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 pendingDiscard}
<DiscardConfirmDialog
file={pendingDiscard.file}
staged={pendingDiscard.staged}
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
{isBusy}
onConfirm={confirmDiscard}
onClose={closeDiscardConfirm}
/>
{/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}
<!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen}
<AiSettingsDialog
settings={aiSettings}
localModels={localModelOptions}
onSave={saveAiSettings}
onClose={() => { aiSettingsOpen = false; }}
/>
{/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}