<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}
