<script lang="ts">
  import { onDestroy, onMount, tick } from "svelte";
  import { getVersion } from "@tauri-apps/api/app";
  import { listen } from "@tauri-apps/api/event";
  import { getCurrentWindow } from "@tauri-apps/api/window";
  import { open as openDialog } from "@tauri-apps/plugin-dialog";
  import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
  import { AlertCircle, Cherry, CloudOff, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
  import { beginFrontendShutdown } from "./lib/telemetry";

  import TitleBar from "./lib/TitleBar.svelte";
  import RepoToolbar from "./lib/RepoToolbar.svelte";
  import RepoTabs from "./lib/RepoTabs.svelte";
  import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
  import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
  import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
  import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
  import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
  import BranchPanel from "./lib/components/BranchPanel.svelte";
  import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
  import CommandPalette from "./lib/components/CommandPalette.svelte";
  import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte";
  import CommitPanel from "./lib/components/CommitPanel.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 HistoryPanel from "./lib/components/HistoryPanel.svelte";
  import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
  import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
  import ReflogDialog from "./lib/components/ReflogDialog.svelte";
  import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
  import StashPanel from "./lib/components/StashPanel.svelte";
  import StatusPanel from "./lib/components/StatusPanel.svelte";
  import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
  import UpdateToast from "./lib/components/UpdateToast.svelte";

  import {
    amendCommit,
    addRemote,
    addWorktree,
    checkoutBranch,
    cherryPickAbort,
    cherryPickCommit,
    cherryPickContinue,
    cloneRepository,
    commit,
    commitAiGenerate,
    commitAiReview,
    commitAiSplit,
    commitAiLoad,
    commitAiLocalModels,
    commitAiStatus,
    compareCommits,
    cancelCodeSearch,
    cancelFileHistory,
    applyFilePatch,
    createBranch,
    createTag,
    deleteBranch,
    deleteCommitNote,
    deleteTag,
    deleteRemoteBranch,
    deleteRemoteBranches,
    initRepository,
    diffFileAgainstWorkingTree,
    compareFileToParent,
    fetchCommitNotes,
    fetchRemote,
    getCommitNote,
    getFileBlame,
    getGitLfsStatus,
    getStatus,
    installGitLfs,
    lastCommitMessage,
    listBranches,
    listRemotes,
    listStashes,
    listTags,
    listWorktrees,
    listCommits,
    listFileHistory,
    listInteractiveRebaseCommits,
    listReflog,
    listRepositoryFiles,
    mergeBranch,
    mergeAbort,
    mergeContinue,
    openRepoInExplorer,
    openRepositoryFile,
    openRepositoryBundle,
    lockWorktree,
    moveWorktree,
    pruneWorktrees,
    pruneGitLfsObjects,
    pull,
    pullGitLfsObjects,
    push,
    pushCommitNotes,
    pushTag,
    removeRemote,
    removeWorktree,
    repairWorktree,
    revertCommit,
    setBranchUpstream,
    setCommitNote,
    updateRemote,
    renameBranch,
    renameRemoteBranch,
    rebaseAbort,
    rebaseBranch,
    rebaseContinue,
    getRemoteUrl,
    detectExternalTools,
    launchExternalDiff,
    launchExternalMerge,
    launchExternalTool,
    credLoad,
    credSave,
    getFilePatch,
    readConflict,
    resolveConflict,
    resolveConflictSide,
    restoreFileFromCommit,
    restoreReflogEntry,
    restoreFiles,
    restoreToCommit,
    searchCodeIntroductions,
    startInteractiveRebase,
    setSyncBadge,
    stageFiles,
    trackGitLfsPattern,
    stashApply,
    stashDrop,
    stashPop,
    stashPush,
    undoLastCommit,
    unlockWorktree,
    untrackGitLfsPattern,
    unstageFiles,
  } from "./lib/git";

  import type {
    AiReviewResult,
    AiCommitPlan,
    AiSettings,
    AppLanguage,
    AppTheme,
    AnalyticsSettings,
    CommitAiPhase,
    ConflictFile,
    DetectedExternalTool,
    ExplorerNode,
    ExplorerNodeKind,
    ExternalDiffScope,
    ExternalToolsSettings,
    GitBlameLine,
    GitBranch as GitBranchInfo,
    GitCommit,
    GitCommitFile,
    GitCommitComparison,
    GitDiffFile,
    GitFileStatus,
    GitLfsStatus,
    GitRepositoryFile,
    GitRemote,
    PullStrategy,
    GitSearchHit,
    GitStash,
    GitStatus,
    GitTag,
    GitWorktree,
    LocalModelOption,
    PatchApplyAction,
    PreparedResolution,
    RebaseCommit,
    RebasePlanItem,
    ReflogEntry,
    RepositoryBundle,
    StoredCredential,
  } from "./lib/types";
  import {
    defaultExternalToolsSettings,
    externalToolDisplayName,
    normaliseExternalToolsSettings,
    resolveDetectedExternalToolPrograms,
  } from "./lib/externalTools";

  import {
    orgKeyFromUrl,
    isAuthError,
    stripAuthPrefix,
    summarizeGitError,
  } from "./lib/credentials";
  import { trackAnalyticsEvent, type AnalyticsEventProperties } from "./lib/analytics";
  import { setTelemetryEnabled, tracedInvoke } from "./lib/telemetry";

  type UpdateToastState = "available" | "downloading" | "installed" | "error";
  type AppView = "management" | "repository";
  type CredentialAction = "push" | "pull" | "fetch" | "clone" | "rename" | "delete";
  type CredentialMode = "credentials" | "token";
  type PendingDiscard =
    | { kind: "file"; files: GitFileStatus[]; staged: boolean }
    | { kind: "all-changes"; files: GitFileStatus[] }
    | { kind: "patch"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string; scope: "hunk" | "lines" };

  interface RepoTab {
    path: string;
    name: string;
    branch: string | null;
    ahead: number;
    behind: number;
    changed: number;
    lastOpened: number;
  }

  interface RepoTabContextMenu {
    path: string;
    x: number;
    y: number;
  }

  interface CloneRequest {
    remoteUrl: string;
    parentPath: string;
    directoryName: string;
  }

  interface ErrorAutoHideState {
    timer?: ReturnType<typeof setTimeout>;
    message: string;
    remaining: number;
    startedAt: number;
    clearIfCurrent: (message: string) => void;
  }

  interface RepositoryRefreshOptions {
    branches?: boolean;
    tags?: boolean;
    stashes?: boolean;
    commits?: boolean;
    files?: boolean;
    fileHistory?: boolean;
  }

  const OPEN_REPOS_KEY = "gitlite.openRepos.v1";
  const RECENT_REPOS_KEY = "gitlite.recentRepos.v1";
  const FAVORITE_REPOS_KEY = "gitlite.favoriteRepos.v1";
  const REPO_STATUS_CACHE_KEY = "gitlite.repoStatusCache.v1";
  const AI_SETTINGS_KEY = "gitlite.aiSettings.v1";
  const ANALYTICS_SETTINGS_KEY = "gitlite.analyticsSettings.v1";
  const APP_THEME_KEY = "gitlite.theme.v1";
  const APP_LANGUAGE_KEY = "gitlite.language.v1";
  const EXTERNAL_TOOLS_SETTINGS_KEY = "gitlite.externalTools.v1";
  const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
  const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
  const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
  const LEFT_BRANCH_PANEL_HEIGHT_KEY = "gitlite.leftBranchPanelHeight.v1";
  const LEFT_STASH_PANEL_HEIGHT_KEY = "gitlite.leftStashPanelHeight.v1";
  const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1";
  const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2";
  const EXPLORER_PANEL_COLLAPSED_KEY = "gitlite.explorerPanelCollapsed.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 LEFT_SIDEBAR_DEFAULT_WIDTH = 280;
  const LEFT_SIDEBAR_MIN_WIDTH = 220;
  const LEFT_SIDEBAR_MAX_WIDTH = 420;
  const LEFT_BRANCH_PANEL_DEFAULT_HEIGHT = 260;
  const LEFT_BRANCH_PANEL_MIN_HEIGHT = 180;
  const LEFT_BRANCH_PANEL_MAX_HEIGHT = 560;
  const LEFT_STASH_PANEL_DEFAULT_HEIGHT = 190;
  const LEFT_STASH_PANEL_MIN_HEIGHT = 150;
  const LEFT_STASH_PANEL_MAX_HEIGHT = 420;
  const LEFT_EXPLORER_PANEL_MIN_HEIGHT = 220;
  const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
  const HISTORY_ASIDE_MIN_WIDTH = 420;
  const HISTORY_ASIDE_MAX_WIDTH = 920;
  const ERROR_AUTO_HIDE_MS = 6000;
  const COMMIT_HISTORY_PAGE_SIZE = 50;

  // ── State ──────────────────────────────────────────────────────────────────

  let repoPath = "";
  let startupReady = false;
  let pendingStartupRepoPath = "";
  let startupRepositoryRetryTimer: ReturnType<typeof setTimeout> | undefined;
  let unlistenStartupRepository: (() => void) | undefined;
  let activeRepoPath = "";
  let activeView: AppView = "management";
  let repoTabs: RepoTab[] = [];
  let repoTabContextMenu: RepoTabContextMenu | null = null;
  let recentRepoPaths: string[] = [];
  let favoriteRepoPaths: string[] = [];
  // Last-seen branch/ahead/behind/changed for repos that are known (recent/favorites)
  // but not currently open as a tab — keyed by normalized path (repoKey).
  let repoStatusCache: Record<string, RepoTab> = {};
  let repoSearch = "";
  let cloneDialogOpen = false;
  let pullStrategy: PullStrategy = (localStorage.getItem("gitlite.pullStrategy") as PullStrategy) || "merge";
  let selectedRemote = localStorage.getItem("gitlite.selectedRemote") || "";
  let remoteActionForceWithLease = false;
  let remoteActionPrune = false;
  let syncSettingsOpen = false;
  let syncSettingsRemotes: GitRemote[] = [];
  let cloneDialogError = "";
  let cloneDialogErrorTimer: ReturnType<typeof setTimeout> | undefined;
  let pendingClone: CloneRequest | null = null;
  let status: GitStatus | null = null;
  let branches: GitBranchInfo[] = [];
  let tags: GitTag[] = [];
  let stashes: GitStash[] = [];
  let commits: GitCommit[] = [];
  let commitHistoryHasMore = false;
  let commitHistoryLoadingMore = false;
  let commitHistoryLoadError = "";
  let commitHistoryRequestId = 0;
  let repoFiles: GitRepositoryFile[] = [];
  let selectedExplorerPath = "";
  let selectedExplorerKind: ExplorerNodeKind = "file";
  let expandedExplorerPaths = new Set<string>();
  let expandedCommitHashes = new Set<string>();
  let selectedCommitHash = "";
  let commitNoteTarget: GitCommit | null = null;
  let commitNoteRepoPath = "";
  let commitNoteText = "";
  let commitNoteRemotes: GitRemote[] = [];
  let commitNotePreferredRemote = "";
  let commitNoteLoading = false;
  let commitNoteBusy = false;
  let commitNoteError = "";
  let commitNoteStatus = "";
  let fileHistory: GitCommit[] = [];
  let fileHistoryLoading = false;
  let fileHistoryError = "";
  let fileHistoryDialogOpen = false;
  let fileHistoryRequestId = 0;
  let activeFileHistoryRequestId = "";
  let repoOpenRequestId = 0;
  let lastFileHistoryHeadHash = "";
  let commitMessage = "";
  let amendMode = false;
  let preAmendDraftMessage = "";
  let lastLocalAiGeneratedMessage = "";
  let commitAiPhase: CommitAiPhase = "idle";
  let commitAiGenerating = false;
  let commitAiReviewing = false;
  let commitAiSplitting = false;
  let aiCommitPlan: AiCommitPlan | null = null;
  let aiCommitSplitOpen = false;
  let aiReviewResult: AiReviewResult | null = null;
  let aiReviewOpen = false;
  let commitAiPollTimer: ReturnType<typeof setInterval> | undefined;
  let aiSettings: AiSettings = defaultAiSettings();
  let aiSettingsOpen = false;
  let appSettingsOpen = false;
  let helpOpen = false;
  let commandPaletteOpen = false;
  let analyticsNoticeOpen = false;
  let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
  let appTheme: AppTheme = loadThemePreference();
  let appLanguage: AppLanguage = loadLanguagePreference();
  let externalToolsSettings: ExternalToolsSettings = loadExternalToolsSettings();
  let externalToolsConfigured = hasStoredExternalToolsSettings();
  let detectedExternalTools: DetectedExternalTool[] = [];
  let externalToolsDetectionPending = true;
  let externalToolsDetectionUnavailable = false;
  let localModelOptions: LocalModelOption[] = [];
  let errorMessage = "";
  let operation = "";
  let compareFrom = "";
  let compareTo = "";
  let comparison: GitCommitComparison | null = null;
  let comparisonFromLabel = "";
  let comparisonToLabel = "";
  let newBranchCommit: GitCommit | null = null;
  let renameBranchTarget: GitBranchInfo | null = null;
  let deleteBranchTarget: GitBranchInfo | null = null;
  let deleteBranchForce = false;
  let worktreeDialogOpen = false;
  let worktreeInitialBranch = "";
  let worktrees: GitWorktree[] = [];
  let worktreesLoading = false;
  let worktreeError = "";
  let gitLfsDialogOpen = false;
  let gitLfsStatus: GitLfsStatus | null = null;
  let gitLfsLoading = false;
  let gitLfsError = "";
  let compareSelectOpen = false;
  let compareDialogOpen = false;
  let interactiveRebaseOpen = false;
  let interactiveRebaseBase = "";
  let interactiveRebaseCommits: RebaseCommit[] = [];
  let interactiveRebaseLoading = false;
  let interactiveRebaseError = "";
  let reflogOpen = false;
  let reflogEntries: ReflogEntry[] = [];
  let reflogLoading = false;
  let reflogError = "";
  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 blameOpen = false;
  let blameFilePath = "";
  let blameLines: GitBlameLine[] = [];
  let blameLoading = false;
  let blameError = "";
  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 = loadStoredBoolean(AUTO_REFRESH_ENABLED_KEY, true);
  let autoRefreshInFlight = false;
  let credDialogOpen = false;
  let credDialogAction: CredentialAction | null = null;
  let pendingRemoteRename: { remote: string; oldBranch: string; newBranch: string } | null = null;
  let pendingRemoteDelete: { remote: string; branches: string[]; label: string; folder: boolean } | null = null;
  let credDialogError = "";
  let credDialogKey: string | null = null;
  let credDialogUsername = "";
  let credDialogMode: CredentialMode = "credentials";
  const rejectedCredentialKeys = new Set<string>();
  let lastStatusFingerprint = "";
  const AUTO_REFRESH_INTERVAL = 4000;
  let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
  const BACKGROUND_REPO_STATUS_INTERVAL = 30_000;
  const BACKGROUND_REPO_STATUS_BATCH_SIZE = 4;
  const BACKGROUND_FETCH_INTERVAL = 180_000;
  const BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS = 30_000;
  const BACKGROUND_FETCH_OTHER_BATCH_SIZE = 2;
  const STARTUP_SPLASH_MIN_VISIBLE_MS = 850;
  let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
  let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
  let backgroundFetchInFlight = false;
  const backgroundCommitNotesFetches = new Map<string, Promise<boolean>>();
  let backgroundRepoStatusInFlight = false;
  let backgroundRepoStatusIndex = 0;
  let appShuttingDown = false;
  let unlistenCloseRequested: (() => void) | undefined;
  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 errorAutoHideStates: Partial<Record<string, ErrorAutoHideState>> = {};
  let commitPanelHeight = loadCommitPanelHeight();
  let resizingCommitPanel = false;
  let resizeStartY = 0;
  let resizeStartHeight = 0;
  let leftSidebarWidth = loadLeftSidebarWidth();
  let resizingLeftSidebar = false;
  let leftSidebarResizeStartX = 0;
  let leftSidebarResizeStartWidth = 0;
  let leftBranchPanelHeight = loadLeftBranchPanelHeight();
  let resizingLeftBranchPanel = false;
  let leftBranchResizeStartY = 0;
  let leftBranchResizeStartHeight = 0;
  let leftStashPanelHeight = loadLeftStashPanelHeight();
  let resizingLeftStashPanel = false;
  let leftStashResizeStartY = 0;
  let leftStashResizeStartHeight = 0;
  let branchPanelCollapsed = loadStoredBoolean(BRANCH_PANEL_COLLAPSED_KEY, false);
  let stashPanelCollapsed = loadStoredBoolean(STASH_PANEL_COLLAPSED_KEY, false);
  let explorerPanelCollapsed = loadStoredBoolean(EXPLORER_PANEL_COLLAPSED_KEY, false);
  let historyAsideWidth = loadHistoryAsideWidth();
  let resizingHistoryAside = false;
  let historyResizeStartX = 0;
  let historyResizeStartWidth = 0;
  let themeMediaQuery: MediaQueryList | undefined;
  let appVersion = "";

  // ── Derived ────────────────────────────────────────────────────────────────

  $: isBusy = operation.length > 0;
  $: hasRepository = activeRepoPath.length > 0 && status !== null;
  $: workspaceActive = activeView === "repository" && hasRepository;
  $: openingRepo = operation === "Opening repository";
  $: cloningRepo = operation === "Cloning repository";
  $: repoDisplayName = ((repoPath || activeRepoPath).split(/[\\/]/).filter(Boolean).pop()) ?? "";
  $: cloneDisplayName = pendingClone?.directoryName || repoNameFromCloneUrl(pendingClone?.remoteUrl ?? "");
  $: 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;
  $: rebaseInProgress = status?.rebase_in_progress ?? false;
  $: cherryPickInProgress = status?.cherry_pick_in_progress ?? false;
  $: mergeInProgress = status?.merge_in_progress ?? false;
  // Amending/undoing is only offered while the last commit hasn't reached a
  // remote yet: no upstream at all, or the branch is still ahead of it.
  $: canAmend = hasRepository && commits.length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress
    && (!status?.upstream || (status?.ahead ?? 0) > 0);
  // Safety net: if the last commit gets pushed elsewhere (or a conflict/rebase starts)
  // while amend mode is active, drop out of it instead of leaving a stale, hidden toggle.
  $: if (!canAmend && amendMode) {
    amendMode = false;
    commitMessage = preAmendDraftMessage;
    preAmendDraftMessage = "";
  }
  $: canCommit = hasRepository && (amendMode || stagedCount > 0) && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress && !isBusy;
  $: commitBlockReason = rebaseInProgress
    ? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase."
    : cherryPickInProgress
    ? "A cherry-pick is in progress. Resolve conflicts and use Cherry-pick continue or abort it."
    : hasConflicts
    ? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "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);
  $: localBranchUpstreams = Object.fromEntries(
    localBranches.flatMap((branch) => branch.upstream ? [[branch.name, branch.upstream]] : []),
  ) as Record<string, string>;
  $: remoteBranches = branches.filter((b) => b.remote);
  $: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream;
  $: repoSearchTerm = repoSearch.trim().toLowerCase();
  $: openRepoRows = repoTabs.filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
  $: recentRepoRows = recentRepoPaths
    .filter((path) => !repoTabs.some((tab) => sameRepoPath(tab.path, path)))
    .map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
    .filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
  $: favoriteRepoRows = favoriteRepoPaths
    .map((path) => repoRowFromPath(path, repoTabs, repoStatusCache))
    .filter((repo) => repoMatchesSearch(repo, repoSearchTerm));
  $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed);
  $: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
  $: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools);
  $: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools);
  $: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools);
  $: terminalToolName = externalToolDisplayName("terminal", externalToolsSettings.terminal, detectedExternalTools);
  $: fileManagerToolName = externalToolDisplayName("fileManager", externalToolsSettings.fileManager, detectedExternalTools);

  $: applyThemePreference(appTheme);
  $: applyLanguagePreference(appLanguage);

  // ── Lifecycle ──────────────────────────────────────────────────────────────

  onMount(() => {
    themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
    themeMediaQuery.addEventListener("change", handleSystemThemeChange);
    void runStartupSequence();
    void listen("open-startup-repository", () => {
      void receiveStartupRepository();
    }).then((unlisten) => {
      if (appShuttingDown) unlisten();
      else unlistenStartupRepository = unlisten;
    }).catch(() => {
      // Browser preview has no Tauri event system.
    });
    void refreshDetectedExternalTools();
    void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
    window.addEventListener("beforeunload", handleAppShutdown);
    window.addEventListener("pagehide", handleAppShutdown);
    try {
      void getCurrentWindow().onCloseRequested(() => handleAppShutdown()).then((unlisten) => {
        if (appShuttingDown) unlisten();
        else unlistenCloseRequested = unlisten;
      }).catch(() => {
        // Browser preview has no Tauri window; DOM lifecycle events still cover it.
      });
    } catch {
      // getCurrentWindow itself throws synchronously in a plain browser preview.
    }
  });

  onDestroy(() => {
    handleAppShutdown();
    themeMediaQuery?.removeEventListener("change", handleSystemThemeChange);
    window.removeEventListener("beforeunload", handleAppShutdown);
    window.removeEventListener("pagehide", handleAppShutdown);
    unlistenCloseRequested?.();
    unlistenStartupRepository?.();
    if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
    if (startupRepositoryRetryTimer) clearTimeout(startupRepositoryRetryTimer);
    Object.values(errorAutoHideStates).forEach((state) => {
      if (state?.timer) clearTimeout(state.timer);
    });
    // Do not invoke backend cancellation here: Windows may already be shutting
    // down, and starting another IPC/Git operation is precisely what we avoid.
  });

  function handleAppShutdown() {
    if (appShuttingDown) return;
    appShuttingDown = true;
    beginFrontendShutdown();
    if (autoRefreshTimer) { clearInterval(autoRefreshTimer); autoRefreshTimer = undefined; }
    if (backgroundRepoStatusTimer) { clearInterval(backgroundRepoStatusTimer); backgroundRepoStatusTimer = undefined; }
    if (backgroundFetchTimer) { clearInterval(backgroundFetchTimer); backgroundFetchTimer = undefined; }
    if (commitAiPollTimer) { clearInterval(commitAiPollTimer); commitAiPollTimer = undefined; }
  }

  function wait(ms: number): Promise<void> {
    return new Promise((resolve) => window.setTimeout(resolve, ms));
  }

  function waitForAnimationFrame(): Promise<void> {
    // requestAnimationFrame never fires for an unmapped/invisible window on
    // WebKitGTK (Linux) — the compositor frame clock only runs for realized
    // windows. The main window starts hidden until the splashscreen closes,
    // so without a timeout fallback this promise (and the whole startup
    // sequence, including closing the splashscreen) would hang forever on Linux.
    return new Promise((resolve) => {
      let settled = false;
      const settle = () => {
        if (settled) return;
        settled = true;
        resolve();
      };
      requestAnimationFrame(() => settle());
      window.setTimeout(settle, 50);
    });
  }

  async function waitForStartupPaint() {
    await tick();
    await waitForAnimationFrame();
    await waitForAnimationFrame();
  }

  async function closeStartupSplashscreen() {
    try {
      await tracedInvoke("close_splashscreen");
    } catch {
      // Browser preview and failed startup paths should keep working without Tauri.
    }
  }

  function startBackgroundTimers() {
    if (appShuttingDown) return;
    autoRefreshTimer = setInterval(() => { void autoRefreshTick(); }, AUTO_REFRESH_INTERVAL);
    backgroundRepoStatusTimer = setInterval(() => { void backgroundRepoStatusTick(false); }, BACKGROUND_REPO_STATUS_INTERVAL);
    backgroundFetchTimer = setInterval(() => { void backgroundFetchTick(); }, BACKGROUND_FETCH_INTERVAL);
  }

  async function runStartupSequence() {
    const startupStartedAt = performance.now();

    initAnalytics();
    loadRepoLists();

    void checkForUpdates();
    void initCommitAi();

    try {
      await waitForStartupPaint();
    } finally {
      const remainingSplashTime = STARTUP_SPLASH_MIN_VISIBLE_MS - (performance.now() - startupStartedAt);
      if (remainingSplashTime > 0) await wait(remainingSplashTime);

      await closeStartupSplashscreen();
      startupReady = true;
      await receiveStartupRepository();
      startBackgroundTimers();
      // Remote access can take seconds (offline networks, SSH negotiation,
      // credential helpers). It must never hold the startup screen hostage.
      void fetchOpenRepositoriesDuringStartup();
      void backgroundRepoStatusTick(false);
    }
  }

  async function receiveStartupRepository() {
    try {
      const path = await tracedInvoke<string | null>("take_startup_repository");
      if (path) pendingStartupRepoPath = path;
    } catch {
      return;
    }

    if (!startupReady || !pendingStartupRepoPath) return;
    if (isBusy) {
      if (!startupRepositoryRetryTimer) {
        startupRepositoryRetryTimer = setTimeout(() => {
          startupRepositoryRetryTimer = undefined;
          void receiveStartupRepository();
        }, 100);
      }
      return;
    }
    const path = pendingStartupRepoPath;
    pendingStartupRepoPath = "";
    await openRepo(path);
  }

  async function fetchOpenRepositoriesDuringStartup() {
    const paths = uniqueRepoPaths(repoTabs.map((tab) => tab.path));
    if (paths.length === 0 || backgroundFetchInFlight) return;

    let fetchedRepositories = 0;
    let failedRepositories = 0;
    backgroundFetchInFlight = true;
    trackEvent("startup_open_repositories_fetch_started", {
      open_repositories: paths.length,
    });

    try {
      for (const path of paths) {
        try {
          const nextStatus = await fetchRemote(path);
          if (sameRepoPath(path, activeRepoPath)) applyStatus(nextStatus);
          else updateRepoManagementStatus(path, nextStatus);
          fetchedRepositories += 1;
        } catch {
          failedRepositories += 1;
          try {
            updateRepoManagementStatus(path, await getStatus(path));
          } catch {
            // Keep the cached tab data if this repo is unavailable at startup.
          }
        }

        const notesFetched = await backgroundFetchCommitNotes(path);
        if (notesFetched && sameRepoPath(path, activeRepoPath)) {
          try {
            await refreshCommitHistory(path);
          } catch {
            // The active repository may still be opening; its own background pass retries.
          }
        }
      }
    } finally {
      backgroundFetchInFlight = false;
      trackEvent("startup_open_repositories_fetch_finished", {
        open_repositories: paths.length,
        fetched_repositories: fetchedRepositories,
        failed_repositories: failedRepositories,
      });
    }
  }

  $: scheduleAutoHideError("errorMessage", errorMessage, (message) => {
    if (errorMessage === message) errorMessage = "";
  });
  $: scheduleAutoHideError("linePatchError", linePatchError, (message) => {
    if (linePatchError === message) linePatchError = "";
  });
  $: scheduleAutoHideError("globalSearchError", globalSearchError, (message) => {
    if (globalSearchError === message) globalSearchError = "";
  });
  $: scheduleAutoHideError("credDialogError", credDialogError, (message) => {
    if (credDialogError === message) credDialogError = "";
  });
  $: scheduleAutoHideError("updateError", updateError, (message) => {
    if (updateError === message) updateError = "";
    if (updateToastState === "error") updateToastOpen = false;
  });
  $: scheduleAutoHideError(
    "updateErrorToast",
    updateToastOpen && updateToastState === "error" ? (updateError || "Update failed") : "",
    () => {
      if (updateToastState === "error") updateToastOpen = false;
    },
  );

  // ── 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.
  function preferredNotesRemote(remotes: GitRemote[]): GitRemote | undefined {
    return remotes.find((remote) => remote.name === selectedRemote)
      ?? remotes.find((remote) => remote.name === "origin")
      ?? remotes[0];
  }

  async function backgroundFetchCommitNotesCore(path: string): Promise<boolean> {
    if (appShuttingDown || !autoRefreshEnabled || !path) return false;
    if (commitNoteTarget && sameRepoPath(path, commitNoteRepoPath)) return false;

    let credentialKey: string | null = null;
    try {
      const remote = preferredNotesRemote(await listRemotes(path));
      if (!remote) return false;

      credentialKey = orgKeyFromUrl(remote.fetch_url);
      if (credentialKey && rejectedCredentialKeys.has(credentialKey)) return false;
      const credential = await loadStoredCredential(credentialKey);
      if (/^https?:\/\//i.test(remote.fetch_url) && !credential) return false;
      await fetchCommitNotes(path, remote.name, credential?.username, credential?.password);
      if (credentialKey) rejectedCredentialKeys.delete(credentialKey);
      trackEvent("commit_notes_background_fetched");
      return true;
    } catch (error) {
      if (credentialKey && isAuthError(errorToMessage(error))) {
        rejectedCredentialKeys.add(credentialKey);
      }
      if (import.meta.env.DEV) console.info("[Gitty notes] Background fetch skipped", errorToMessage(error));
      return false;
    }
  }

  function backgroundFetchCommitNotes(path: string): Promise<boolean> {
    const key = repoKey(path);
    const current = backgroundCommitNotesFetches.get(key);
    if (current) return current;

    const request = backgroundFetchCommitNotesCore(path).finally(() => {
      backgroundCommitNotesFetches.delete(key);
    });
    backgroundCommitNotesFetches.set(key, request);
    return request;
  }

  async function waitForBackgroundCommitNotes(path: string) {
    await backgroundCommitNotesFetches.get(repoKey(path));
  }

  async function backgroundFetchCommitNotesAndRefresh(path: string) {
    if (!await backgroundFetchCommitNotes(path)) return;
    if (!sameRepoPath(path, activeRepoPath)) return;
    try {
      await refreshCommitHistory(path);
    } catch {
      // Repository changes can invalidate this best-effort background refresh.
    }
  }

  async function backgroundFetchTick() {
    if (appShuttingDown || !autoRefreshEnabled) return;

    if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
      && !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
      const path = activeRepoPath;
      backgroundFetchInFlight = true;
      try {
        let refsFetched = false;
        try {
          await fetchRemote(path);
          refsFetched = true;
        } catch {
          // Manual Fetch/Pull surfaces remote errors; background work stays silent.
        }

        const notesFetched = await backgroundFetchCommitNotes(path);
        if (sameRepoPath(path, activeRepoPath)) {
          if (refsFetched) {
            applyStatus(await getStatus(path));
            await refreshRefsAndCommitGraph(path);
          } else if (notesFetched) {
            await refreshCommitHistory(path);
          }
        }
      } catch {
        // ignore transient refresh failures
      } finally {
        backgroundFetchInFlight = false;
      }
    }

    void backgroundRepoStatusTick(true);
  }

  async function backgroundFetchRepo(path: string) {
    if (appShuttingDown || !autoRefreshEnabled || !path || backgroundFetchInFlight) return;

    backgroundFetchInFlight = true;
    try {
      let refsFetched = false;
      try {
        await fetchRemote(path);
        refsFetched = true;
      } catch {
        // Manual Fetch/Pull surfaces remote errors; background work stays silent.
      }

      const notesFetched = await backgroundFetchCommitNotes(path);
      if (refsFetched) {
        const nextStatus = await getStatus(path);
        if (sameRepoPath(path, activeRepoPath)) {
          applyStatus(nextStatus);
          await refreshRefsAndCommitGraph(path);
        } else updateRepoManagementStatus(path, nextStatus);
      } else if (notesFetched && sameRepoPath(path, activeRepoPath)) {
        await refreshCommitHistory(path);
      }
    } catch {
      // ignore transient refresh failures
    } finally {
      backgroundFetchInFlight = false;
    }
  }

  // Keeps the repo list's branch/ahead/behind up to date for every *other* open
  // tab, not just the active one, so switching to the management view (or just
  // glancing at the tab bar) shows current data without an explicit fetch.
  // One repo per tick, round-robin: spreads the git subprocess cost over time
  // instead of firing N fetches at once when many repos are open.
  // Every repo we know about besides the active one: open tabs (minus the active
  // tab) plus recent/favorite repos that aren't currently open — the same
  // universe the Repository Management lists draw from.
  function knownRepoPathsForBackground(): string[] {
    return uniqueRepoPaths([...repoTabs.map((tab) => tab.path), ...recentRepoPaths, ...favoriteRepoPaths])
      .filter((path) => !(activeView === "repository" && sameRepoPath(path, activeRepoPath)));
  }

  function updateRepoManagementStatus(path: string, nextStatus: GitStatus) {
    const openTab = repoTabs.find((tab) => sameRepoPath(tab.path, path));
    const cached = repoStatusCache[repoKey(path)];
    const row: RepoTab = {
      path,
      name: repoNameFromPath(path),
      branch: nextStatus.current_branch,
      ahead: nextStatus.ahead,
      behind: nextStatus.behind,
      changed: nextStatus.files.length,
      lastOpened: openTab?.lastOpened ?? cached?.lastOpened ?? 0,
    };

    if (openTab) {
      repoTabs = repoTabs.map((tab) => sameRepoPath(tab.path, path) ? row : tab);
    }
    cacheRepoStatus(row);
  }

  async function backgroundRepoStatusTick(fetchFirst: boolean) {
    if (appShuttingDown || !autoRefreshEnabled || backgroundRepoStatusInFlight) return;
    const others = knownRepoPathsForBackground();
    if (others.length === 0) return;

    backgroundRepoStatusInFlight = true;
    try {
      // A handful per tick, sequentially (not in parallel) — enough to fill in
      // a long recent-repos list within a few minutes instead of an hour, while
      // still never running more than one `git fetch` subprocess at a time.
      const batchSize = fetchFirst ? BACKGROUND_FETCH_OTHER_BATCH_SIZE : BACKGROUND_REPO_STATUS_BATCH_SIZE;
      for (let step = 0; step < Math.min(batchSize, others.length); step++) {
        if (appShuttingDown) break;
        if (backgroundRepoStatusIndex >= others.length) backgroundRepoStatusIndex = 0;
        const path = others[backgroundRepoStatusIndex];
        backgroundRepoStatusIndex += 1;

        try {
          if (fetchFirst) await fetchRemote(path);
          const nextStatus = await getStatus(path);
          updateRepoManagementStatus(path, nextStatus);
        } catch {
          // ignore this repo — same rationale as the active-repo background fetch above
        }
      }
    } finally {
      backgroundRepoStatusInFlight = false;
    }
  }

  async function autoRefreshTick() {
    if (appShuttingDown || !autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || gitLfsDialogOpen || newBranchCommit || commitNoteTarget || globalSearchOpen || helpOpen) return;
    const path = activeRepoPath;
    autoRefreshInFlight = true;
    try {
      // Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
      const nextStatus = await getStatus(path);
      // The user may have switched repos (or closed this one) while the status
      // call was in flight — applying a stale result would flash/overwrite the
      // now-active repo's name and data with this one's.
      if (!sameRepoPath(path, activeRepoPath)) return;
      if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
      // Something changed — reload branches, commits and files in one bundled call.
      const bundle = await openRepositoryBundle(path, Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1);
      if (!sameRepoPath(path, activeRepoPath)) return;
      await applyRepositoryBundle(path, bundle);
      // 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.
    } 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 defaultAnalyticsSettings(): AnalyticsSettings {
    return {
      enabled: true,
      noticeSeen: false,
    };
  }

  function loadAnalyticsSettings(): AnalyticsSettings {
    try {
      const stored = JSON.parse(localStorage.getItem(ANALYTICS_SETTINGS_KEY) ?? "null") as unknown;
      if (stored && typeof stored === "object") {
        return { ...defaultAnalyticsSettings(), ...(stored as Partial<AnalyticsSettings>) };
      }
    } catch { /* ignore malformed settings */ }
    return defaultAnalyticsSettings();
  }

  function persistAnalyticsSettings(next: AnalyticsSettings) {
    try {
      localStorage.setItem(ANALYTICS_SETTINGS_KEY, JSON.stringify(next));
    } catch { /* ignore storage quota/private-mode errors */ }
  }

  function initAnalytics() {
    analyticsSettings = loadAnalyticsSettings();
    setTelemetryEnabled(analyticsSettings.noticeSeen && analyticsSettings.enabled);
    if (!analyticsSettings.noticeSeen) {
      analyticsNoticeOpen = true;
      return;
    }
    trackEvent("app_started");
  }

  function trackEvent(name: string, props?: AnalyticsEventProperties) {
    if (!analyticsSettings.enabled) return;
    trackAnalyticsEvent(name, props);
  }

  function acceptAnalyticsNotice(enabled: boolean) {
    analyticsSettings = { enabled, noticeSeen: true };
    persistAnalyticsSettings(analyticsSettings);
    analyticsNoticeOpen = false;
    setTelemetryEnabled(enabled);
    trackEvent("app_started", { first_run: 1 });
  }

  function loadThemePreference(): AppTheme {
    try {
      const stored = localStorage.getItem(APP_THEME_KEY);
      if (stored === "system" || stored === "light" || stored === "dark") return stored;
    } catch {
      // Local storage is optional; system theme is a sane default.
    }
    return "system";
  }

  function persistThemePreference(next: AppTheme) {
    try {
      localStorage.setItem(APP_THEME_KEY, next);
    } catch {
      // Ignore storage quota/private-mode errors.
    }
  }

  function loadLanguagePreference(): AppLanguage {
    try {
      const stored = localStorage.getItem(APP_LANGUAGE_KEY);
      if (stored === "en" || stored === "de") return stored;
    } catch {
      // Local storage is optional; English is the first-start default.
    }
    return "en";
  }

  function persistLanguagePreference(next: AppLanguage) {
    try {
      localStorage.setItem(APP_LANGUAGE_KEY, next);
    } catch {
      // Ignore storage quota/private-mode errors.
    }
  }

  function applyLanguagePreference(next: AppLanguage) {
    document.documentElement.lang = next;
    document.documentElement.dataset.language = next;
  }

  function applyThemePreference(next: AppTheme) {
    const prefersLight = themeMediaQuery?.matches ?? window.matchMedia("(prefers-color-scheme: light)").matches;
    const resolved = next === "system"
      ? prefersLight
        ? "light"
        : "dark"
      : next;
    document.documentElement.dataset.themePreference = next;
    document.documentElement.dataset.theme = resolved;
  }

  function handleSystemThemeChange() {
    if (appTheme === "system") applyThemePreference(appTheme);
  }

  function saveAppSettings(next: AnalyticsSettings, nextTheme: AppTheme, nextLanguage: AppLanguage, nextAutoRefresh: boolean, nextExternalTools: ExternalToolsSettings) {
    const autoRefreshWasEnabled = autoRefreshEnabled;
    analyticsSettings = next;
    appTheme = nextTheme;
    appLanguage = nextLanguage;
    autoRefreshEnabled = nextAutoRefresh;
    externalToolsSettings = nextExternalTools;
    persistAnalyticsSettings(next);
    persistThemePreference(nextTheme);
    persistLanguagePreference(nextLanguage);
    persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
    persistExternalToolsSettings(nextExternalTools);
    externalToolsConfigured = true;
    setTelemetryEnabled(next.enabled);
    appSettingsOpen = false;
    if (nextAutoRefresh && !autoRefreshWasEnabled) void autoRefreshTick();
    if (next.enabled) trackEvent("settings_saved", { analytics_enabled: 1, theme: nextTheme, language: nextLanguage, auto_refresh: nextAutoRefresh ? 1 : 0 });
  }

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

  async function reviewStagedWithAi() {
    if (!activeRepoPath || commitAiReviewing || stagedCount === 0) return;
    if (aiSettings.provider === "local") {
      errorMessage = "Pre-commit review currently requires OpenAI, Anthropic, or a custom endpoint.";
      return;
    }
    commitAiReviewing = true;
    errorMessage = "";
    try {
      if (aiSettings.provider === "openai") {
        const cred = await credLoad("ai:openai");
        aiReviewResult = await commitAiReview(activeRepoPath, {
          provider: "openai",
          model: aiSettings.openaiModel,
          apiKey: cred?.password,
        });
      } else if (aiSettings.provider === "anthropic") {
        const cred = await credLoad("ai:anthropic");
        aiReviewResult = await commitAiReview(activeRepoPath, {
          provider: "anthropic",
          model: aiSettings.anthropicModel,
          apiKey: cred?.password,
        });
      } else {
        const cred = await credLoad("ai:custom");
        aiReviewResult = await commitAiReview(activeRepoPath, {
          provider: "custom",
          model: aiSettings.customModel,
          baseUrl: aiSettings.customBaseUrl,
          apiKey: cred?.password,
        });
      }
      aiReviewOpen = true;
      trackEvent("ai_precommit_review", {
        provider: aiSettings.provider,
        findings: aiReviewResult.findings.length,
        risk: aiReviewResult.risk,
      });
    } catch (error) {
      errorMessage = errorToMessage(error);
    } finally {
      commitAiReviewing = false;
    }
  }

  async function splitStagedWithAi() {
    if (!activeRepoPath || commitAiSplitting || stagedCount < 2) return;
    if (aiSettings.provider === "local") {
      errorMessage = "Commit splitting currently requires OpenAI, Anthropic, or a custom endpoint.";
      return;
    }
    commitAiSplitting = true;
    errorMessage = "";
    try {
      if (aiSettings.provider === "openai") {
        const cred = await credLoad("ai:openai");
        aiCommitPlan = await commitAiSplit(activeRepoPath, { provider: "openai", model: aiSettings.openaiModel, apiKey: cred?.password });
      } else if (aiSettings.provider === "anthropic") {
        const cred = await credLoad("ai:anthropic");
        aiCommitPlan = await commitAiSplit(activeRepoPath, { provider: "anthropic", model: aiSettings.anthropicModel, apiKey: cred?.password });
      } else {
        const cred = await credLoad("ai:custom");
        aiCommitPlan = await commitAiSplit(activeRepoPath, {
          provider: "custom", model: aiSettings.customModel, baseUrl: aiSettings.customBaseUrl, apiKey: cred?.password,
        });
      }
      aiCommitSplitOpen = true;
      trackEvent("ai_commit_split_planned", { provider: aiSettings.provider, groups: aiCommitPlan.groups.length });
    } catch (error) {
      errorMessage = errorToMessage(error);
    } finally {
      commitAiSplitting = false;
    }
  }

  async function applyAiCommitPlan(plan: AiCommitPlan) {
    if (!activeRepoPath || isBusy || commitAiSplitting) return;
    const allFiles = plan.groups.flatMap((group) => group.files);
    if (plan.groups.length < 2 || plan.groups.some((group) => !group.message.trim() || group.files.length === 0)) return;
    const currentStaged = changedFiles.filter((file) => file.staged !== null).map((file) => file.path).sort();
    if (currentStaged.join("\n") !== [...allFiles].sort().join("\n")) {
      errorMessage = "The staged files changed after the plan was created. Generate a new split plan.";
      return;
    }
    if (changedFiles.some((file) => file.staged !== null && file.unstaged !== null)) {
      errorMessage = "A file now has both staged and unstaged changes. Stage or discard the remaining changes first.";
      return;
    }

    commitAiSplitting = true;
    await runOperation("Creating split commits", async () => {
      applyStatus(await unstageFiles(activeRepoPath, currentStaged));
      let completed = 0;
      try {
        for (const group of plan.groups) {
          applyStatus(await stageFiles(activeRepoPath, group.files));
          applyStatus(await commit(activeRepoPath, group.message.trim()));
          completed += 1;
        }
      } catch (error) {
        throw new Error(`${completed} of ${plan.groups.length} commits were created. Remaining changes are preserved and can be staged again. ${errorToMessage(error)}`);
      }
      aiCommitSplitOpen = false;
      aiCommitPlan = null;
      commitMessage = "";
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("ai_commit_split_applied", { groups: plan.groups.length, files: allFiles.length });
    });
    commitAiSplitting = false;
  }

  // ── 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 repoNameFromCloneUrl(url: string): string {
    const trimmed = url.trim().split(/[?#]/, 1)[0]?.replace(/[\\/]+$/g, "") ?? "";
    const lastSegment = trimmed.split(/[\\/:]/).filter(Boolean).pop() ?? "";
    return lastSegment.replace(/\.git$/i, "").trim();
  }

  function setCloneDialogError(message: string) {
    if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
    cloneDialogError = message;
    if (message) {
      cloneDialogErrorTimer = setTimeout(() => {
        if (cloneDialogError === message) cloneDialogError = "";
      }, ERROR_AUTO_HIDE_MS);
    }
  }

  function scheduleAutoHideError(
    key: string,
    message: string,
    clearIfCurrent: (message: string) => void,
  ) {
    const existing = errorAutoHideStates[key];
    if (existing?.timer) {
      clearTimeout(existing.timer);
    }
    delete errorAutoHideStates[key];

    if (!message) return;

    errorAutoHideStates[key] = {
      message,
      remaining: ERROR_AUTO_HIDE_MS,
      startedAt: Date.now(),
      clearIfCurrent,
    };
    resumeAutoHideError(key);
  }

  function pauseAutoHideError(key: string) {
    const state = errorAutoHideStates[key];
    if (!state?.timer) return;
    clearTimeout(state.timer);
    state.timer = undefined;
    state.remaining = Math.max(0, state.remaining - (Date.now() - state.startedAt));
  }

  function resumeAutoHideError(key: string) {
    const state = errorAutoHideStates[key];
    if (!state || state.timer) return;
    state.startedAt = Date.now();
    state.timer = setTimeout(() => {
      state.clearIfCurrent(state.message);
      delete errorAutoHideStates[key];
    }, state.remaining);
  }

  function repoKey(path: string): string {
    return path.replace(/\\/g, "/").trim().toLowerCase();
  }

  function sameRepoPath(left: string, right: string): boolean {
    return repoKey(left) === repoKey(right);
  }

  function baseName(path: string): string {
    return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
  }

  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, tabs = repoTabs, statusCache = repoStatusCache): RepoTab {
    const openTab = tabs.find((tab) => sameRepoPath(tab.path, path));
    if (openTab) return openTab;

    const cached = statusCache[repoKey(path)];
    if (cached) return { ...cached, path, name: repoNameFromPath(path) };

    return {
      path,
      name: repoNameFromPath(path),
      branch: null,
      ahead: 0,
      behind: 0,
      changed: 0,
      lastOpened: 0,
    };
  }

  function repoMatchesSearch(repo: RepoTab, searchTerm = repoSearchTerm): boolean {
    if (!searchTerm) return true;
    return repo.name.toLowerCase().includes(searchTerm)
      || repo.path.toLowerCase().includes(searchTerm)
      || (repo.branch ?? "").toLowerCase().includes(searchTerm);
  }

  function loadRepoLists() {
    try {
      const cacheValue = JSON.parse(localStorage.getItem(REPO_STATUS_CACHE_KEY) ?? "{}") as unknown;
      repoStatusCache = cacheValue && typeof cacheValue === "object" && !Array.isArray(cacheValue)
        ? cacheValue as Record<string, RepoTab>
        : {};

      const openValue = JSON.parse(localStorage.getItem(OPEN_REPOS_KEY) ?? "[]") as unknown;
      const recentValue = JSON.parse(localStorage.getItem(RECENT_REPOS_KEY) ?? "[]") as unknown;
      const favoriteValue = JSON.parse(localStorage.getItem(FAVORITE_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)
        : [];
      const favoritePaths = Array.isArray(favoriteValue)
        ? favoriteValue.map((item) => typeof item === "string" ? item : "").filter(Boolean)
        : [];

      // Seed from the last-known status cache so tabs show real data immediately
      // on startup, instead of blank until the background poll catches up.
      repoTabs = uniqueRepoPaths(openPaths).map((path) => {
        const cached = repoStatusCache[repoKey(path)];
        return cached
          ? { ...cached, path, name: repoNameFromPath(path) }
          : { path, name: repoNameFromPath(path), branch: null, ahead: 0, behind: 0, changed: 0, lastOpened: 0 };
      });
      recentRepoPaths = uniqueRepoPaths([...recentPaths, ...openPaths]);
      favoriteRepoPaths = uniqueRepoPaths(favoritePaths);
    } catch {
      repoTabs = [];
      recentRepoPaths = [];
      favoriteRepoPaths = [];
      repoStatusCache = {};
    }
  }

  function persistRepoLists() {
    try {
      localStorage.setItem(OPEN_REPOS_KEY, JSON.stringify(repoTabs.map((tab) => tab.path)));
      localStorage.setItem(RECENT_REPOS_KEY, JSON.stringify(recentRepoPaths));
      localStorage.setItem(FAVORITE_REPOS_KEY, JSON.stringify(favoriteRepoPaths));
    } catch {
      // Local storage is best-effort only; the Git workflow must keep working without it.
    }
  }

  function persistRepoStatusCache() {
    try {
      localStorage.setItem(REPO_STATUS_CACHE_KEY, JSON.stringify(repoStatusCache));
    } 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 loadStoredBoolean(key: string, fallback: boolean): boolean {
    try {
      const stored = localStorage.getItem(key);
      if (stored === "true") return true;
      if (stored === "false") return false;
    } catch {
      // Local storage is best-effort only; panel defaults are enough.
    }
    return fallback;
  }

  function persistStoredBoolean(key: string, value: boolean) {
    try {
      localStorage.setItem(key, String(value));
    } catch {
      // Local storage is best-effort only; toggles 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 clampLeftSidebarWidth(value: number): number {
    return Math.min(LEFT_SIDEBAR_MAX_WIDTH, Math.max(LEFT_SIDEBAR_MIN_WIDTH, Math.round(value)));
  }

  function loadLeftSidebarWidth(): number {
    try {
      const stored = Number(localStorage.getItem(LEFT_SIDEBAR_WIDTH_KEY));
      if (Number.isFinite(stored) && stored > 0) return clampLeftSidebarWidth(stored);
    } catch {
      // Fall through to the default below.
    }
    return LEFT_SIDEBAR_DEFAULT_WIDTH;
  }

  function persistLeftSidebarWidth(value: number) {
    try {
      localStorage.setItem(LEFT_SIDEBAR_WIDTH_KEY, String(value));
    } catch {
      // Local storage is best-effort only; resizing must keep working without it.
    }
  }

  function clampLeftBranchPanelHeight(value: number): number {
    return Math.min(LEFT_BRANCH_PANEL_MAX_HEIGHT, Math.max(LEFT_BRANCH_PANEL_MIN_HEIGHT, Math.round(value)));
  }

  function loadLeftBranchPanelHeight(): number {
    try {
      const stored = Number(localStorage.getItem(LEFT_BRANCH_PANEL_HEIGHT_KEY));
      if (Number.isFinite(stored) && stored > 0) return clampLeftBranchPanelHeight(stored);
    } catch {
      // Fall through to the default below.
    }
    return LEFT_BRANCH_PANEL_DEFAULT_HEIGHT;
  }

  function persistLeftBranchPanelHeight(value: number) {
    try {
      localStorage.setItem(LEFT_BRANCH_PANEL_HEIGHT_KEY, String(value));
    } catch {
      // Local storage is best-effort only; resizing must keep working without it.
    }
  }

  function clampLeftStashPanelHeight(value: number): number {
    return Math.min(LEFT_STASH_PANEL_MAX_HEIGHT, Math.max(LEFT_STASH_PANEL_MIN_HEIGHT, Math.round(value)));
  }

  function loadLeftStashPanelHeight(): number {
    try {
      const stored = Number(localStorage.getItem(LEFT_STASH_PANEL_HEIGHT_KEY));
      if (Number.isFinite(stored) && stored > 0) return clampLeftStashPanelHeight(stored);
    } catch {
      // Fall through to the default below.
    }
    return LEFT_STASH_PANEL_DEFAULT_HEIGHT;
  }

  function persistLeftStashPanelHeight(value: number) {
    try {
      localStorage.setItem(LEFT_STASH_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 startLeftSidebarResize(event: PointerEvent) {
    event.preventDefault();
    resizingLeftSidebar = true;
    leftSidebarResizeStartX = event.clientX;
    leftSidebarResizeStartWidth = leftSidebarWidth;
    (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
  }

  function onLeftSidebarResizeMove(event: PointerEvent) {
    if (!resizingLeftSidebar) return;
    leftSidebarWidth = clampLeftSidebarWidth(leftSidebarResizeStartWidth + (event.clientX - leftSidebarResizeStartX));
  }

  function endLeftSidebarResize(event: PointerEvent) {
    if (!resizingLeftSidebar) return;
    resizingLeftSidebar = false;
    persistLeftSidebarWidth(leftSidebarWidth);
    const target = event.currentTarget as HTMLElement;
    if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
  }

  function onLeftSidebarResizeKeydown(event: KeyboardEvent) {
    if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
    event.preventDefault();
    leftSidebarWidth = clampLeftSidebarWidth(leftSidebarWidth + (event.key === "ArrowRight" ? 20 : -20));
    persistLeftSidebarWidth(leftSidebarWidth);
  }

  function buildLeftSidebarRows(branchCollapsed: boolean, stashCollapsed: boolean, explorerCollapsed: boolean): string {
    const branchRow = branchCollapsed
      ? "auto"
      : `minmax(${LEFT_BRANCH_PANEL_MIN_HEIGHT}px, var(--branch-panel-height, ${LEFT_BRANCH_PANEL_DEFAULT_HEIGHT}px))`;
    const branchHandleRow = branchCollapsed ? "0" : "14px";
    const stashRow = stashCollapsed
      ? "auto"
      : `minmax(${LEFT_STASH_PANEL_MIN_HEIGHT}px, var(--stash-panel-height, ${LEFT_STASH_PANEL_DEFAULT_HEIGHT}px))`;
    const stashHandleRow = explorerCollapsed || (stashCollapsed && branchCollapsed) ? "0" : "14px";
    const explorerRow = explorerCollapsed ? "auto" : `minmax(${LEFT_EXPLORER_PANEL_MIN_HEIGHT}px, 1fr)`;

    return `${branchRow} ${branchHandleRow} ${stashRow} ${stashHandleRow} ${explorerRow}`;
  }

  function startLeftBranchPanelResize(event: PointerEvent) {
    if (branchPanelCollapsed) return;
    event.preventDefault();
    resizingLeftBranchPanel = true;
    leftBranchResizeStartY = event.clientY;
    leftBranchResizeStartHeight = leftBranchPanelHeight;
    (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
  }

  function onLeftBranchPanelResizeMove(event: PointerEvent) {
    if (!resizingLeftBranchPanel) return;
    leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchResizeStartHeight + (event.clientY - leftBranchResizeStartY));
  }

  function endLeftBranchPanelResize(event: PointerEvent) {
    if (!resizingLeftBranchPanel) return;
    resizingLeftBranchPanel = false;
    persistLeftBranchPanelHeight(leftBranchPanelHeight);
    const target = event.currentTarget as HTMLElement;
    if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
  }

  function onLeftBranchPanelResizeKeydown(event: KeyboardEvent) {
    if (branchPanelCollapsed || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
    event.preventDefault();
    leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchPanelHeight + (event.key === "ArrowDown" ? 20 : -20));
    persistLeftBranchPanelHeight(leftBranchPanelHeight);
  }

  function startLeftStashPanelResize(event: PointerEvent) {
    if (stashPanelCollapsed && branchPanelCollapsed) return;
    event.preventDefault();
    resizingLeftStashPanel = true;
    leftStashResizeStartY = event.clientY;
    leftStashResizeStartHeight = stashPanelCollapsed ? leftBranchPanelHeight : leftStashPanelHeight;
    (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
  }

  function onLeftStashPanelResizeMove(event: PointerEvent) {
    if (!resizingLeftStashPanel) return;
    if (stashPanelCollapsed) {
      leftBranchPanelHeight = clampLeftBranchPanelHeight(leftStashResizeStartHeight + (event.clientY - leftStashResizeStartY));
    } else {
      leftStashPanelHeight = clampLeftStashPanelHeight(leftStashResizeStartHeight + (event.clientY - leftStashResizeStartY));
    }
  }

  function endLeftStashPanelResize(event: PointerEvent) {
    if (!resizingLeftStashPanel) return;
    resizingLeftStashPanel = false;
    if (stashPanelCollapsed) persistLeftBranchPanelHeight(leftBranchPanelHeight);
    else persistLeftStashPanelHeight(leftStashPanelHeight);
    const target = event.currentTarget as HTMLElement;
    if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
  }

  function onLeftStashPanelResizeKeydown(event: KeyboardEvent) {
    if ((stashPanelCollapsed && branchPanelCollapsed) || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) return;
    event.preventDefault();
    if (stashPanelCollapsed) {
      leftBranchPanelHeight = clampLeftBranchPanelHeight(leftBranchPanelHeight + (event.key === "ArrowDown" ? 20 : -20));
      persistLeftBranchPanelHeight(leftBranchPanelHeight);
    } else {
      leftStashPanelHeight = clampLeftStashPanelHeight(leftStashPanelHeight + (event.key === "ArrowDown" ? 20 : -20));
      persistLeftStashPanelHeight(leftStashPanelHeight);
    }
  }

  function toggleBranchPanelCollapsed() {
    branchPanelCollapsed = !branchPanelCollapsed;
    persistStoredBoolean(BRANCH_PANEL_COLLAPSED_KEY, branchPanelCollapsed);
  }

  function toggleStashPanelCollapsed() {
    stashPanelCollapsed = !stashPanelCollapsed;
    persistStoredBoolean(STASH_PANEL_COLLAPSED_KEY, stashPanelCollapsed);
  }

  function toggleExplorerPanelCollapsed() {
    explorerPanelCollapsed = !explorerPanelCollapsed;
    persistStoredBoolean(EXPLORER_PANEL_COLLAPSED_KEY, explorerPanelCollapsed);
  }

  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 hideAndResetFileHistory() {
    fileHistoryRequestId += 1;
    cancelActiveFileHistoryLoad();
    activeFileHistoryRequestId = "";
    fileHistoryLoading = false;
    fileHistoryError = "";
    fileHistory = [];
    fileHistoryDialogOpen = false;
  }

  function rememberRecentRepo(path: string) {
    recentRepoPaths = uniqueRepoPaths([path, ...recentRepoPaths]).slice(0, 40);
    persistRepoLists();
  }

  function isFavoriteRepo(path: string): boolean {
    return favoriteRepoPaths.some((favoritePath) => sameRepoPath(favoritePath, path));
  }

  function toggleFavoriteRepo(path: string, event?: MouseEvent) {
    event?.stopPropagation();
    if (isBusy) return;
    const wasFavorite = isFavoriteRepo(path);
    favoriteRepoPaths = wasFavorite
      ? favoriteRepoPaths.filter((favoritePath) => !sameRepoPath(favoritePath, path))
      : uniqueRepoPaths([path, ...favoriteRepoPaths]);
    persistRepoLists();
    trackEvent(wasFavorite ? "repository_favorite_removed" : "repository_favorite_added", {
      favorite_repositories: favoriteRepoPaths.length,
    });
  }

  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);
    cacheRepoStatus(next);
  }

  // Keeps last-known branch/ahead/behind/changed around under the repo's
  // normalized path, independent of repoTabs — so a closed tab (or a repo
  // that's only ever shown up in "recent") still displays real data instead
  // of the "known repo" placeholder in the Recent/Favorites repositories lists.
  function cacheRepoStatus(row: RepoTab) {
    repoStatusCache = { ...repoStatusCache, [repoKey(row.path)]: row };
    persistRepoStatusCache();
  }

  function resetRepositoryState(clearActive = false) {
    if (clearActive) {
      activeRepoPath = "";
      repoPath = "";
      status = null;
      lastStatusFingerprint = "";
      void setSyncBadge(0, 0, 0).catch(() => {});
    }
    branches = [];
    stashes = [];
    commits = [];
    commitHistoryHasMore = false;
    commitHistoryLoadingMore = false;
    commitHistoryLoadError = "";
    commitHistoryRequestId += 1;
    lastFileHistoryHeadHash = "";
    repoFiles = [];
    selectedExplorerPath = "";
    selectedExplorerKind = "file";
    expandedExplorerPaths = new Set();
    expandedCommitHashes = new Set();
    commitNoteTarget = null;
    commitNoteRepoPath = "";
    commitNoteText = "";
    commitNoteRemotes = [];
    commitNoteLoading = false;
    commitNoteBusy = false;
    commitNoteError = "";
    commitNoteStatus = "";
    fileHistory = [];
    fileHistoryLoading = false;
    fileHistoryError = "";
    fileHistoryDialogOpen = false;
    compareFrom = "";
    compareTo = "";
    comparison = null;
    comparisonFromLabel = "";
    comparisonToLabel = "";
    compareSelectOpen = false;
    compareDialogOpen = false;
    interactiveRebaseOpen = false;
    interactiveRebaseBase = "";
    interactiveRebaseCommits = [];
    interactiveRebaseError = "";
    reflogOpen = false;
    reflogEntries = [];
    reflogError = "";
    selectedDiffPath = "";
    pendingRestoreFile = null;
    newBranchCommit = null;
    globalSearchResults = [];
    deleteBranchTarget = null;
    deleteBranchForce = false;
    pendingRemoteDelete = null;
    worktreeDialogOpen = false;
    worktreeInitialBranch = "";
    worktrees = [];
    worktreeError = "";
    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 {
    let message: string;
    if (error instanceof Error) message = error.message;
    else if (typeof error === "string") message = error;
    else try { message = JSON.stringify(error) ?? "Unknown error"; } catch { message = "Unknown error"; }
    return message;
  }

  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");
  }

  function isBranchNotFullyMergedError(message: string): boolean {
    const value = message.toLowerCase();
    return value.includes("not fully merged") || value.includes("run 'git branch -d'");
  }

  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[]) {
    const nextBranches = prefetched ?? (await listBranches(path));
    if (!sameRepoPath(path, activeRepoPath)) return;
    branches = nextBranches;
  }

  async function refreshTags(path = activeRepoPath, prefetched?: GitTag[]) {
    const nextTags = prefetched ?? (await listTags(path));
    if (!sameRepoPath(path, activeRepoPath)) return;
    tags = nextTags;
  }

  async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
    const nextStashes = prefetched ?? (await listStashes(path));
    if (!sameRepoPath(path, activeRepoPath)) return;
    stashes = nextStashes;
  }

  async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
    const targetLimit = prefetched
      ? Math.max(COMMIT_HISTORY_PAGE_SIZE, prefetched.length - 1)
      : Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length);
    const requestId = ++commitHistoryRequestId;
    commitHistoryLoadingMore = false;
    commitHistoryLoadError = "";
    const history = prefetched ?? (await listCommits(path, targetLimit + 1, 0));
    if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return;

    commits = history.slice(0, targetLimit);
    commitHistoryHasMore = history.length > targetLimit;
    commitHistoryLoadingMore = false;
    commitHistoryLoadError = "";
    lastFileHistoryHeadHash = commits[0]?.hash ?? "";
    const targets = new Set([
      ...commits.map((commit) => commit.hash),
      ...branches.map(compareRefForBranch),
    ]);
    if (compareFrom && !targets.has(compareFrom)) compareFrom = "";
    if (compareTo && !targets.has(compareTo)) compareTo = "";
  }

  async function loadMoreCommitHistory() {
    if (!activeRepoPath || commitHistoryLoadingMore || !commitHistoryHasMore || isBusy) return;

    const path = activeRepoPath;
    const offset = commits.length;
    const requestId = ++commitHistoryRequestId;
    commitHistoryLoadingMore = true;
    commitHistoryLoadError = "";

    try {
      const history = await listCommits(path, COMMIT_HISTORY_PAGE_SIZE + 1, offset);
      if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return;

      const knownHashes = new Set(commits.map((commit) => commit.hash));
      const nextPage = history
        .slice(0, COMMIT_HISTORY_PAGE_SIZE)
        .filter((commit) => !knownHashes.has(commit.hash));
      commits = [...commits, ...nextPage];
      commitHistoryHasMore = history.length > COMMIT_HISTORY_PAGE_SIZE;
    } catch (error) {
      if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return;
      commitHistoryLoadError = errorToMessage(error);
    } finally {
      if (requestId === commitHistoryRequestId) commitHistoryLoadingMore = false;
    }
  }

  async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
    const nextFiles = prefetched ?? (await listRepositoryFiles(path));
    if (!sameRepoPath(path, activeRepoPath)) return;
    repoFiles = nextFiles;
    const folderPaths = allExplorerFolderPaths(repoFiles);
    expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder)));
    if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
      selectedExplorerPath = "";
      selectedExplorerKind = "file";
      hideAndResetFileHistory();
    }
  }

  async function refreshRepositoryViews(
    path = activeRepoPath,
    options: RepositoryRefreshOptions = {},
  ) {
    const tasks: Promise<void>[] = [];
    if (options.branches ?? true) tasks.push(refreshBranchList(path));
    if (options.tags ?? false) tasks.push(refreshTags(path));
    if (options.stashes ?? false) tasks.push(refreshStashes(path));
    if (options.commits ?? true) tasks.push(refreshCommitHistory(path));
    if (options.files ?? true) tasks.push(refreshExplorerFiles(path));

    // These reads do not depend on each other. Starting them together removes
    // several IPC/Git-process waterfalls after every user operation.
    await Promise.all(tasks);

    // Explorer refresh may invalidate the selected path, so file history runs
    // after the parallel group rather than racing a disappearing selection.
    if (options.fileHistory ?? true) await refreshFileHistory(path);
  }

  async function applyRepositoryBundle(
    path: string,
    bundle: RepositoryBundle,
    forceFileHistory = false,
  ) {
    const previousHeadHash = lastFileHistoryHeadHash;
    applyStatus(bundle.status);
    const resolvedPath = activeRepoPath || path;
    await Promise.all([
      refreshBranchList(resolvedPath, bundle.branches),
      refreshTags(resolvedPath, bundle.tags),
      refreshStashes(resolvedPath, bundle.stashes),
      refreshCommitHistory(resolvedPath, bundle.commits),
      refreshExplorerFiles(resolvedPath, bundle.files),
    ]);

    if (forceFileHistory || lastFileHistoryHeadHash !== previousHeadHash) {
      await refreshFileHistory(resolvedPath);
    }
  }

  async function refreshRepositorySnapshot(path = activeRepoPath, forceFileHistory = false) {
    const bundle = await openRepositoryBundle(
      path,
      Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1,
    );
    if (!sameRepoPath(path, activeRepoPath)) return;
    await applyRepositoryBundle(path, bundle, forceFileHistory);
  }

  async function refreshRefsAndCommitGraph(path = activeRepoPath) {
    const previousHeadHash = lastFileHistoryHeadHash;
    await Promise.all([
      refreshBranchList(path),
      refreshTags(path),
      refreshCommitHistory(path),
    ]);
    if (lastFileHistoryHeadHash !== previousHeadHash && fileHistoryDialogOpen) {
      await refreshFileHistory(path);
    }
  }

  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, force = false) {
    if (!force && !fileHistoryDialogOpen && !globalSearchOpen) return;

    const requestId = ++fileHistoryRequestId;
    cancelActiveFileHistoryLoad();

    if (!path || !file) {
      activeFileHistoryRequestId = "";
      fileHistoryLoading = false;
      fileHistory = [];
      return;
    }

    const historyRequestId = `file-history-${requestId}-${Date.now()}`;
    activeFileHistoryRequestId = historyRequestId;
    fileHistoryLoading = true;
    fileHistoryError = "";
    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)) {
          fileHistoryError = message;
          if (!fileHistoryDialogOpen) 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; }
    const requestId = ++repoOpenRequestId;
    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, COMMIT_HISTORY_PAGE_SIZE + 1);
      if (requestId !== repoOpenRequestId) return;
      resetRepositoryState(false);
      await applyRepositoryBundle(path, bundle);
      if (globalSearchBusy) void cancelGlobalSearch();
      activeView = "repository";
      lastRepoSwitchAt = Date.now();
      trackEvent("repository_opened", {
        changed_files: bundle.status.files.length,
        has_upstream: bundle.status.upstream ? 1 : 0,
      });
      if (backgroundFetchInFlight) void backgroundFetchCommitNotesAndRefresh(activeRepoPath);
      else void backgroundFetchRepo(activeRepoPath);
    });
  }

  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;
      trackEvent("repository_folder_selected");
      await openRepo(selected);
    } catch (error) {
      errorMessage = errorToMessage(error);
    }
  }

  async function cloneRepo(
    remoteUrl: string,
    parentPath: string,
    directoryName: string,
    username?: string,
    password?: string,
    key?: string | null,
    fromStore = false,
    credentialMode: CredentialMode = "credentials",
  ) {
    if (isBusy) return;
    if (!remoteUrl) { errorMessage = "Enter a remote URL."; return; }
    if (!parentPath) { errorMessage = "Select a destination folder."; return; }

    const request: CloneRequest = { remoteUrl, parentPath, directoryName };
    pendingClone = request;
    const credentialKey = key === undefined ? orgKeyFromUrl(remoteUrl) : key;

    if (!username && !password) {
      const stored = await loadStoredCredential(credentialKey);
      if (stored) {
        const storedMode = credentialModeFor(stored);
        if (credentialKey && rejectedCredentialKeys.has(credentialKey)) {
          credDialogUsername = stored.username === "oauth2" ? "" : stored.username;
          credDialogMode = storedMode;
          credDialogAction = "clone";
          credDialogKey = credentialKey;
          credDialogOpen = true;
          return;
        }
        await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true, storedMode);
        return;
      }
    }

    operation = "Cloning repository";
    errorMessage = "";
    setCloneDialogError("");
    try {
      const bundle = await cloneRepository(
        remoteUrl,
        parentPath,
        directoryName || undefined,
        username,
        password,
        COMMIT_HISTORY_PAGE_SIZE + 1,
      );
      resetRepositoryState(false);
      await applyRepositoryBundle(bundle.status.repo_path, bundle);
      if (globalSearchBusy) void cancelGlobalSearch();
      activeView = "repository";
      cloneDialogOpen = false;
      pendingClone = null;
      if (credDialogAction === "clone") {
        credDialogOpen = false;
        credDialogAction = null;
        credDialogError = "";
        credDialogKey = null;
      }
      lastRepoSwitchAt = Date.now();
      trackEvent("repository_cloned", {
        changed_files: bundle.status.files.length,
        has_upstream: bundle.status.upstream ? 1 : 0,
      });
    } catch (error) {
      const rawMessage = errorToMessage(error);
      const message = stripAuthPrefix(rawMessage);
      if (isAuthError(rawMessage)) {
        errorMessage = "";
        setCloneDialogError("");
        if (fromStore) {
          if (credentialKey) rejectedCredentialKeys.add(credentialKey);
          credDialogUsername = username === "oauth2" ? "" : (username ?? "");
          credDialogMode = credentialMode;
          const detail = summarizeGitError(message);
          credDialogError = detail
            ? `${detail} — please sign in again.`
            : "Credentials were rejected or have expired. Please sign in again.";
        } else {
          credDialogError = message || "Sign-in is required to clone this repository.";
        }
        credDialogAction = "clone";
        credDialogKey = credentialKey;
        credDialogOpen = true;
      } else {
        setCloneDialogError(message);
        errorMessage = "";
      }
    } finally {
      operation = "";
    }
  }

  function openCloneDialog() {
    if (isBusy) return;
    setCloneDialogError("");
    cloneDialogOpen = true;
    trackEvent("clone_dialog_opened");
  }

  function openRepoManagement() {
    if (isBusy) return;
    activeView = "management";
    trackEvent("repository_management_opened", {
      open_repositories: repoTabs.length,
      recent_repositories: recentRepoPaths.length,
      favorite_repositories: favoriteRepoPaths.length,
    });
    void backgroundRepoStatusTick(false);
  }

  async function selectRepoTab(path: string) {
    if (isBusy) return;
    if (activeView === "repository" && sameRepoPath(activeRepoPath, path)) return;
    closeRepoTabContextMenu();
    trackEvent("repository_tab_selected", {
      open_repositories: repoTabs.length,
    });
    await openRepo(path);
  }

  function repoTabIndex(path: string): number {
    return repoTabs.findIndex((tab) => sameRepoPath(tab.path, path));
  }

  function closeRepoTabContextMenu() {
    repoTabContextMenu = null;
  }

  function openRepoTabContextMenu(path: string, event: MouseEvent) {
    event.preventDefault();
    event.stopPropagation();
    if (isBusy) return;
    repoTabContextMenu = {
      path,
      x: Math.max(8, Math.min(event.clientX, window.innerWidth - 210)),
      y: Math.max(8, Math.min(event.clientY, window.innerHeight - 128)),
    };
  }

  async function closeRepoTabFromContext(path: string) {
    closeRepoTabContextMenu();
    await closeRepoTab(path);
  }

  async function closeRepoTab(path: string, event?: MouseEvent) {
    event?.stopPropagation();
    if (isBusy) return;
    closeRepoTabContextMenu();

    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;
    const wasActive = sameRepoPath(activeRepoPath, path);
    repoTabs = remaining;
    persistRepoLists();
    trackEvent("repository_tab_closed", {
      open_repositories: repoTabs.length,
      was_active: wasActive ? 1 : 0,
    });

    if (!wasActive) return;
    if (next) {
      // Switch identity immediately. Otherwise a status/fetch request for the
      // just-closed repository can still finish while it remains active and
      // reinsert its tab through applyStatus().
      repoOpenRequestId += 1;
      activeRepoPath = next.path;
      repoPath = next.path;
      status = null;
      lastStatusFingerprint = "";
      resetRepositoryState(false);
      await openRepo(next.path);
    } else {
      repoOpenRequestId += 1;
      resetRepositoryState(true);
      activeView = "management";
    }
  }

  async function closeOtherRepoTabs(path: string) {
    if (isBusy) return;
    closeRepoTabContextMenu();
    const target = repoTabs.find((tab) => sameRepoPath(tab.path, path));
    if (!target || repoTabs.length <= 1) return;

    const closedCount = repoTabs.length - 1;
    const wasActive = sameRepoPath(activeRepoPath, path);
    repoTabs = [target];
    persistRepoLists();
    trackEvent("repository_tabs_closed", {
      mode: "others",
      closed_tabs: closedCount,
      open_repositories: repoTabs.length,
    });

    if (!wasActive) await openRepo(path);
  }

  async function closeRepoTabsToRight(path: string) {
    if (isBusy) return;
    closeRepoTabContextMenu();
    const index = repoTabIndex(path);
    if (index < 0 || index >= repoTabs.length - 1) return;

    const remaining = repoTabs.slice(0, index + 1);
    const closedCount = repoTabs.length - remaining.length;
    const activeStillOpen = remaining.some((tab) => sameRepoPath(tab.path, activeRepoPath));
    repoTabs = remaining;
    persistRepoLists();
    trackEvent("repository_tabs_closed", {
      mode: "right",
      closed_tabs: closedCount,
      open_repositories: repoTabs.length,
    });

    if (!activeStillOpen) await openRepo(path);
  }

  async function removeRepoFromRecent(path: string, event?: MouseEvent) {
    event?.stopPropagation();
    if (isBusy) return;
    recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
    persistRepoLists();
    trackEvent("repository_removed_from_recent", {
      recent_repositories: recentRepoPaths.length,
    });
    if (!repoTabs.some((tab) => sameRepoPath(tab.path, path)) && !isFavoriteRepo(path) && repoStatusCache[repoKey(path)]) {
      const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
      repoStatusCache = rest;
      persistRepoStatusCache();
    }
  }

  async function removeRepoFromManagement(path: string, event?: MouseEvent) {
    event?.stopPropagation();
    if (isBusy) return;
    const wasOpen = repoTabs.some((tab) => sameRepoPath(tab.path, path));
    const wasFavorite = isFavoriteRepo(path);
    recentRepoPaths = recentRepoPaths.filter((recentPath) => !sameRepoPath(recentPath, path));
    favoriteRepoPaths = favoriteRepoPaths.filter((favoritePath) => !sameRepoPath(favoritePath, path));
    persistRepoLists();
    trackEvent("repository_removed_from_management", {
      was_open: wasOpen ? 1 : 0,
      was_favorite: wasFavorite ? 1 : 0,
      recent_repositories: recentRepoPaths.length,
      favorite_repositories: favoriteRepoPaths.length,
    });
    if (repoStatusCache[repoKey(path)]) {
      const { [repoKey(path)]: _removed, ...rest } = repoStatusCache;
      repoStatusCache = rest;
      persistRepoStatusCache();
    }
    if (wasOpen) {
      await closeRepoTab(path);
    }
  }

  async function openActiveRepoInExplorer() {
    if (!activeRepoPath || isBusy) return;
    try {
      await openRepoInExplorer(activeRepoPath);
      trackEvent("repository_opened_in_explorer");
    } catch (error) {
      errorMessage = errorToMessage(error);
    }
  }

  async function refreshRepo() {
    if (!activeRepoPath) { await openRepo(); return; }
    await runOperation("Refreshing", async () => {
      await refreshRepositorySnapshot(activeRepoPath, true);
      trackEvent("repository_refreshed", {
        changed_files: status?.files.length ?? 0,
      });
    });
  }

  async function checkout(branch: GitBranchInfo) {
    if (!activeRepoPath || branch.current) return;
    await runOperation(`Checking out ${branch.name}`, async () => {
      applyStatus(await checkoutBranch(activeRepoPath, branch.name));
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("branch_checked_out", {
        remote: branch.remote ? 1 : 0,
      });
    });
  }

  async function createNewBranch(branchName: string) {
    const name = branchName.trim();
    if (!activeRepoPath || !name) return;
    await runOperation(`Creating ${name}`, async () => {
      applyStatus(await createBranch(activeRepoPath, name));
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("branch_created");
    });
  }

  function openRenameBranchDialog(branch: GitBranchInfo) {
    if (!activeRepoPath) return;
    if (branch.remote && branch.name.indexOf("/") < 1) {
      errorMessage = "Could not determine remote name.";
      return;
    }
    renameBranchTarget = branch;
    trackEvent("branch_rename_dialog_opened", { remote: branch.remote ? 1 : 0 });
  }

  async function submitRenameBranch(branchName: string) {
    const branch = renameBranchTarget;
    const name = branchName.trim();
    if (!activeRepoPath || !branch || !name) return;

    if (branch.remote) {
      const slash = branch.name.indexOf("/");
      if (slash < 1) {
        errorMessage = "Could not determine remote name.";
        return;
      }
      const remote = branch.name.slice(0, slash);
      const oldRemoteBranch = branch.name.slice(slash + 1);
      if (name === oldRemoteBranch) return;

      pendingRemoteRename = { remote, oldBranch: oldRemoteBranch, newBranch: name };
      const key = await currentCredKey("rename");
      const stored = await loadStoredCredential(key);
      if (stored && (!key || !rejectedCredentialKeys.has(key))) {
        await doActualRemoteRename(stored.username, stored.password, key, true, credentialModeFor(stored));
      } else {
        await openCredentialDialog("rename", key, stored);
      }
      return;
    }

    if (name === branch.name) return;

    await runOperation(`Renaming ${branch.name}`, async () => {
      applyStatus(await renameBranch(activeRepoPath, branch.name, name));
      renameBranchTarget = null;
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("branch_renamed", { remote: 0 });
    });
  }

  async function deleteLocalBranch(branch: GitBranchInfo) {
    if (!activeRepoPath || branch.remote) return;
    if (branch.current) {
      errorMessage = "The current branch cannot be deleted.";
      return;
    }

    deleteBranchTarget = branch;
    deleteBranchForce = false;
    trackEvent("branch_delete_dialog_opened");
  }

  async function deleteBranchFolder(folderName: string, folderBranches: GitBranchInfo[], folderDepth: number) {
    if (!activeRepoPath || isBusy) return;
    const remoteFolder = folderBranches.every((branch) => branch.remote);
    if (remoteFolder && folderDepth === 0) {
      errorMessage = "The top-level remote folder cannot be deleted as a group.";
      return;
    }
    const deletableBranches = folderBranches.filter((branch) => !branch.current);
    const currentBranchKept = folderBranches.some((branch) => branch.current);
    if (deletableBranches.length === 0) {
      errorMessage = "The folder only contains the current branch, which cannot be deleted.";
      return;
    }

    const scope = remoteFolder ? "remote" : "local";
    const currentNote = currentBranchKept ? "\n\nThe current branch will be kept." : "";
    if (!window.confirm(`Delete ${deletableBranches.length} ${scope} branches in “${folderName}”?${currentNote}`)) return;

    const repoPath = activeRepoPath;
    if (remoteFolder) {
      const remoteBranches = deletableBranches.map((branch) => {
        const slash = branch.name.indexOf("/");
        if (slash < 1) throw new Error(`Could not determine the remote for ${branch.name}.`);
        return { remote: branch.name.slice(0, slash), name: branch.name.slice(slash + 1) };
      });
      const remote = remoteBranches[0]?.remote;
      if (!remote || remoteBranches.some((branch) => branch.remote !== remote)) {
        errorMessage = "A remote branch folder must belong to exactly one remote.";
        return;
      }
      pendingRemoteDelete = { remote, branches: remoteBranches.map((branch) => branch.name), label: folderName, folder: true };
      const key = await currentCredKey("delete");
      const stored = await loadStoredCredential(key);
      if (stored && (!key || !rejectedCredentialKeys.has(key))) {
        await doActualRemoteDelete(stored.username, stored.password, key, true, credentialModeFor(stored));
      } else {
        await openCredentialDialog("delete", key, stored);
      }
      return;
    }

    const failures: string[] = [];
    operation = `Deleting branches in ${folderName}`;
    errorMessage = "";
    try {
      for (const branch of deletableBranches) {
        try {
          applyStatus(await deleteBranch(repoPath, branch.name, false));
        } catch (error) {
          failures.push(`${branch.name}: ${errorToMessage(error)}`);
        }
      }
      await refreshRepositoryViews(repoPath);
      trackEvent("branch_folder_deleted", {
        attempted: deletableBranches.length,
        failed: failures.length,
        remote: 0,
      });
      if (failures.length > 0) {
        errorMessage = `${failures.length} branch${failures.length === 1 ? "" : "es"} could not be deleted:\n${failures.join("\n")}`;
      }
    } finally {
      operation = "";
    }
  }

  async function confirmDeleteBranch() {
    const branch = deleteBranchTarget;
    if (!activeRepoPath || !branch || branch.current || isBusy) return;

    if (branch.remote) {
      const slash = branch.name.indexOf("/");
      if (slash < 1) { errorMessage = "Could not determine remote name."; return; }
      const remote = branch.name.slice(0, slash);
      const remoteBranch = branch.name.slice(slash + 1);
      pendingRemoteDelete = { remote, branches: [remoteBranch], label: branch.name, folder: false };
      const key = await currentCredKey("delete");
      const stored = await loadStoredCredential(key);
      if (stored && (!key || !rejectedCredentialKeys.has(key))) {
        await doActualRemoteDelete(stored.username, stored.password, key, true, credentialModeFor(stored));
      } else {
        await openCredentialDialog("delete", key, stored);
      }
      return;
    }

    operation = `${deleteBranchForce ? "Force deleting" : "Deleting"} ${branch.name}`;
    errorMessage = "";
    try {
      const forceDelete = deleteBranchForce;
      applyStatus(await deleteBranch(activeRepoPath, branch.name, deleteBranchForce));
      deleteBranchTarget = null;
      deleteBranchForce = false;
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("branch_deleted", {
        force: forceDelete ? 1 : 0,
      });
    } catch (error) {
      const message = errorToMessage(error);
      if (deleteBranchForce || !isBranchNotFullyMergedError(message)) {
        errorMessage = message;
        return;
      }

      deleteBranchForce = true;
    } finally {
      operation = "";
    }
  }

  function closeDeleteBranchDialog() {
    if (isBusy) return;
    deleteBranchTarget = null;
    deleteBranchForce = false;
  }

  async function openWorktreeDialog(branch = "") {
    if (!activeRepoPath || isBusy) return;
    worktreeInitialBranch = branch;
    worktreeDialogOpen = true;
    worktreeError = "";
    worktreesLoading = true;
    try {
      worktrees = await listWorktrees(activeRepoPath);
      trackEvent("worktree_dialog_opened", { linked_worktrees: Math.max(0, worktrees.length - 1) });
    } catch (error) {
      worktreeError = errorToMessage(error);
    } finally {
      worktreesLoading = false;
    }
  }

  function openBranchInWorktree(branch: GitBranchInfo) {
    if (branch.remote) return;
    void openWorktreeDialog(branch.name);
  }

  async function refreshWorktrees() {
    if (!activeRepoPath || worktreesLoading) return;
    worktreesLoading = true;
    worktreeError = "";
    try {
      worktrees = await listWorktrees(activeRepoPath);
    } catch (error) {
      worktreeError = errorToMessage(error);
    } finally {
      worktreesLoading = false;
    }
  }

  async function runWorktreeOperation(
    label: string,
    task: () => Promise<GitWorktree[]>,
    eventName: string,
  ): Promise<boolean> {
    if (!activeRepoPath || isBusy) return false;
    operation = label;
    worktreeError = "";
    try {
      worktrees = await task();
      await refreshBranchList(activeRepoPath);
      trackEvent(eventName, { linked_worktrees: Math.max(0, worktrees.length - 1) });
      return true;
    } catch (error) {
      worktreeError = errorToMessage(error);
      return false;
    } finally {
      operation = "";
    }
  }

  async function createWorktree(request: {
    worktreePath: string;
    branch?: string;
    newBranch?: string;
    startPoint?: string;
    detached?: boolean;
    lock?: boolean;
  }): Promise<boolean> {
    return runWorktreeOperation(
      "Creating worktree",
      () => addWorktree(activeRepoPath, request.worktreePath, request),
      "worktree_created",
    );
  }

  async function openWorktreeTab(worktree: GitWorktree) {
    if (worktree.missing || worktree.bare || isBusy) return;
    worktreeDialogOpen = false;
    worktreeInitialBranch = "";
    await openRepo(worktree.path);
  }

  async function removeSelectedWorktree(worktree: GitWorktree, force: boolean): Promise<boolean> {
    if (repoTabs.some((tab) => sameRepoPath(tab.path, worktree.path))) {
      worktreeError = "Close this worktree's repository tab before removing it.";
      return false;
    }
    return runWorktreeOperation(
      `Removing ${worktree.branch || "worktree"}`,
      () => removeWorktree(activeRepoPath, worktree.path, force),
      "worktree_removed",
    );
  }

  async function moveSelectedWorktree(worktree: GitWorktree, destination: string) {
    if (repoTabs.some((tab) => sameRepoPath(tab.path, worktree.path))) {
      worktreeError = "Close this worktree's repository tab before moving it.";
      return;
    }
    await runWorktreeOperation(
      `Moving ${worktree.branch || "worktree"}`,
      () => moveWorktree(activeRepoPath, worktree.path, destination),
      "worktree_moved",
    );
  }

  function lockSelectedWorktree(worktree: GitWorktree, reason: string): Promise<boolean> {
    return runWorktreeOperation(
      `Locking ${worktree.branch || "worktree"}`,
      () => lockWorktree(activeRepoPath, worktree.path, reason),
      "worktree_locked",
    );
  }

  async function unlockSelectedWorktree(worktree: GitWorktree) {
    await runWorktreeOperation(
      `Unlocking ${worktree.branch || "worktree"}`,
      () => unlockWorktree(activeRepoPath, worktree.path),
      "worktree_unlocked",
    );
  }

  async function pruneStaleWorktrees() {
    await runWorktreeOperation(
      "Pruning stale worktrees",
      () => pruneWorktrees(activeRepoPath),
      "worktrees_pruned",
    );
  }

  async function repairSelectedWorktree(worktree: GitWorktree, location: string) {
    await runWorktreeOperation(
      `Repairing ${worktree.branch || "worktree"}`,
      () => repairWorktree(activeRepoPath, location),
      "worktree_repaired",
    );
  }

  function closeWorktreeDialog() {
    if (isBusy) return;
    worktreeDialogOpen = false;
    worktreeInitialBranch = "";
    worktreeError = "";
  }

  async function openGitLfsDialog() {
    if (!activeRepoPath || isBusy) return;
    gitLfsDialogOpen = true;
    gitLfsStatus = null;
    gitLfsError = "";
    await refreshGitLfsStatus();
    const inspectedStatus = gitLfsStatus as GitLfsStatus | null;
    trackEvent("git_lfs_dialog_opened", {
      available: inspectedStatus?.available ? 1 : 0,
      repository_uses_lfs: inspectedStatus?.repository_uses_lfs ? 1 : 0,
    });
  }

  async function refreshGitLfsStatus() {
    if (!activeRepoPath || gitLfsLoading) return;
    gitLfsLoading = true;
    gitLfsError = "";
    try {
      gitLfsStatus = await getGitLfsStatus(activeRepoPath);
    } catch (error) {
      gitLfsError = errorToMessage(error);
    } finally {
      gitLfsLoading = false;
    }
  }

  async function runGitLfsOperation(
    label: string,
    task: () => Promise<GitLfsStatus>,
    eventName: string,
  ): Promise<boolean> {
    if (!activeRepoPath || isBusy) return false;
    const repository = activeRepoPath;
    operation = label;
    gitLfsError = "";
    try {
      gitLfsStatus = await task();
      applyStatus(await getStatus(repository));
      await refreshExplorerFiles(repository);
      trackEvent(eventName, {
        patterns: gitLfsStatus.patterns.length,
        files: gitLfsStatus.files.length,
      });
      return true;
    } catch (error) {
      gitLfsError = errorToMessage(error);
      return false;
    } finally {
      operation = "";
    }
  }

  function activateGitLfs() {
    return runGitLfsOperation(
      "Activating Git LFS",
      () => installGitLfs(activeRepoPath),
      "git_lfs_activated",
    );
  }

  function addGitLfsPattern(pattern: string, lockable: boolean): Promise<boolean> {
    return runGitLfsOperation(
      `Tracking ${pattern} with Git LFS`,
      () => trackGitLfsPattern(activeRepoPath, pattern, lockable),
      "git_lfs_pattern_added",
    );
  }

  async function removeGitLfsPattern(pattern: string) {
    await runGitLfsOperation(
      `Removing Git LFS pattern ${pattern}`,
      () => untrackGitLfsPattern(activeRepoPath, pattern),
      "git_lfs_pattern_removed",
    );
  }

  async function pullGitLfsFiles() {
    if (!activeRepoPath || isBusy) return;
    let credential: StoredCredential | null = null;
    const key = await currentCredKey("pull");
    if (key) credential = await credLoad(key);
    await runGitLfsOperation(
      "Downloading Git LFS objects",
      () => pullGitLfsObjects(
        activeRepoPath,
        selectedRemote || undefined,
        credential?.username,
        credential?.password,
      ),
      "git_lfs_objects_pulled",
    );
  }

  async function pruneGitLfsCache() {
    await runGitLfsOperation(
      "Pruning Git LFS cache",
      () => pruneGitLfsObjects(activeRepoPath),
      "git_lfs_cache_pruned",
    );
  }

  function closeGitLfsDialog() {
    if (isBusy) return;
    gitLfsDialogOpen = false;
    gitLfsStatus = null;
    gitLfsError = "";
  }

  function openNewBranchDialog(commit: GitCommit) {
    if (!activeRepoPath || isBusy) return;
    newBranchCommit = commit;
    trackEvent("branch_from_commit_dialog_opened");
  }

  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 refreshRepositoryViews(activeRepoPath);
      trackEvent("branch_created_from_commit");
    });
  }

  async function merge(branch: GitBranchInfo) {
    if (!activeRepoPath || branch.current) return;
    const strategy = (window.prompt("Merge strategy: default, squash, ff-only, or no-ff", "default") ?? "").trim();
    if (!strategy) return;
    if (!["default", "squash", "ff-only", "no-ff"].includes(strategy)) { errorMessage = "Unknown merge strategy."; return; }
    await runOperation(`Merging ${branch.name}`, async () => {
      applyStatus(await mergeBranch(activeRepoPath, branch.name, strategy as import("./lib/types").MergeStrategy));
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("branch_merged", {
        remote: branch.remote ? 1 : 0,
      });
    });
  }

  async function rebaseOnto(branch: GitBranchInfo) {
    if (!activeRepoPath || branch.current || rebaseInProgress || cherryPickInProgress) return;
    await runOperation(`Rebasing onto ${branch.name}`, async () => {
      applyStatus(await rebaseBranch(activeRepoPath, branch.name));
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("branch_rebased", {
        remote: branch.remote ? 1 : 0,
      });
    });
  }

  async function continueRebase() {
    if (!activeRepoPath || !rebaseInProgress || hasConflicts) return;
    await runOperation("Continuing rebase", async () => {
      applyStatus(await rebaseContinue(activeRepoPath));
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("rebase_continued");
    });
  }

  async function abortRebase() {
    if (!activeRepoPath || !rebaseInProgress) return;
    const confirmed = window.confirm("Abort the current rebase and return to the previous state?");
    if (!confirmed) return;

    await runOperation("Aborting rebase", async () => {
      applyStatus(await rebaseAbort(activeRepoPath));
      preparedResolutions = {};
      resolveDialogOpen = false;
      conflict = null;
      conflictTarget = "";
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("rebase_aborted");
    });
  }

  function preferredInteractiveRebaseBase(): string {
    const candidates = [status?.upstream, "origin/main", "main", "origin/master", "master"]
      .filter((value): value is string => Boolean(value) && value !== status?.current_branch);
    for (const candidate of candidates) {
      if (branches.some((branch) => branch.name === candidate)) return candidate;
    }
    return branches.find((branch) => !branch.current)?.name ?? "";
  }

  async function loadInteractiveRebaseRange(base: string) {
    interactiveRebaseBase = base;
    interactiveRebaseCommits = [];
    interactiveRebaseError = "";
    if (!activeRepoPath || !base) return;
    interactiveRebaseLoading = true;
    try {
      const result = await listInteractiveRebaseCommits(activeRepoPath, base);
      if (interactiveRebaseBase === base) interactiveRebaseCommits = result;
    } catch (error) {
      if (interactiveRebaseBase === base) interactiveRebaseError = errorToMessage(error);
    } finally {
      if (interactiveRebaseBase === base) interactiveRebaseLoading = false;
    }
  }

  function openInteractiveRebase() {
    if (!hasRepository || rebaseInProgress || cherryPickInProgress || isBusy) return;
    interactiveRebaseOpen = true;
    const base = preferredInteractiveRebaseBase();
    void loadInteractiveRebaseRange(base);
    trackEvent("interactive_rebase_opened");
  }

  async function runInteractiveRebase(plan: RebasePlanItem[]) {
    if (!activeRepoPath || !interactiveRebaseBase || isBusy) return;
    interactiveRebaseError = "";
    await runOperation("Starting interactive rebase", async () => {
      applyStatus(await startInteractiveRebase(activeRepoPath, interactiveRebaseBase, plan));
      interactiveRebaseOpen = false;
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("interactive_rebase_started", { commits: plan.length });
    });
    if (interactiveRebaseOpen && errorMessage) interactiveRebaseError = errorMessage;
  }

  async function openReflog() {
    if (!hasRepository || isBusy) return;
    reflogOpen = true;
    reflogEntries = [];
    reflogError = "";
    reflogLoading = true;
    try {
      reflogEntries = await listReflog(activeRepoPath, 300);
      trackEvent("reflog_opened", { entries: reflogEntries.length });
    } catch (error) {
      reflogError = errorToMessage(error);
    } finally {
      reflogLoading = false;
    }
  }

  async function previewReflogEntry(entry: ReflogEntry) {
    if (!activeRepoPath || isBusy) return;
    await runOperation("Previewing reflog entry", async () => {
      const result = await compareCommits(activeRepoPath, entry.hash, "HEAD");
      comparison = result;
      comparisonFromLabel = entry.selector;
      comparisonToLabel = "HEAD";
      selectedDiffPath = result.files[0]?.path ?? "";
      diffHighlightQuery = "";
      pendingRestoreFile = null;
      reflogOpen = false;
      compareDialogOpen = true;
      trackEvent("reflog_previewed", { files: result.files.length });
    });
  }

  async function recoverReflogEntry(entry: ReflogEntry, branch: string) {
    if (!activeRepoPath || !branch.trim() || isBusy) return;
    reflogError = "";
    await runOperation("Restoring reflog entry", async () => {
      applyStatus(await restoreReflogEntry(activeRepoPath, entry.hash, branch.trim()));
      reflogOpen = false;
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("reflog_recovered");
    });
    if (reflogOpen && errorMessage) reflogError = errorMessage;
  }

  async function createNewTag(name: string, message: string) {
    const trimmed = name.trim();
    if (!activeRepoPath || !trimmed) return;
    await runOperation(`Creating tag ${trimmed}`, async () => {
      await refreshTags(activeRepoPath, await createTag(activeRepoPath, trimmed, undefined, message));
      trackEvent("tag_created", {
        annotated: message.trim() ? 1 : 0,
      });
    });
  }

  async function deleteLocalTag(tag: GitTag) {
    if (!activeRepoPath || isBusy) return;
    const confirmed = window.confirm(`Delete tag '${tag.name}'?\n\nThis only removes the local tag, not any copy already pushed to a remote.`);
    if (!confirmed) return;

    await runOperation(`Deleting tag ${tag.name}`, async () => {
      await refreshTags(activeRepoPath, await deleteTag(activeRepoPath, tag.name));
      trackEvent("tag_deleted");
    });
  }

  async function pushLocalTag(tag: GitTag) {
    if (!activeRepoPath || isBusy) return;
    const key = await currentCredKey("push");
    const stored = await loadStoredCredential(key);
    const credential = stored ?? null;

    await runOperation(`Pushing tag ${tag.name}`, async () => {
      try {
        await pushTag(activeRepoPath, tag.name, credential?.username, credential?.password);
      } catch (error) {
        const message = errorToMessage(error);
        throw new Error(
          isAuthError(message)
            ? `Sign in via the Push button first, then retry pushing tag '${tag.name}'.`
            : stripAuthPrefix(message),
        );
      }
      trackEvent("tag_pushed");
    });
  }

  function closeCommitNoteDialog() {
    if (commitNoteBusy) return;
    commitNoteTarget = null;
    commitNoteRepoPath = "";
    commitNoteText = "";
    commitNoteRemotes = [];
    commitNotePreferredRemote = "";
    commitNoteLoading = false;
    commitNoteError = "";
    commitNoteStatus = "";
  }

  async function openCommitNoteDialog(commit: GitCommit) {
    if (!activeRepoPath || commitNoteBusy) return;
    const repo = activeRepoPath;
    selectedCommitHash = commit.hash;
    commitNoteTarget = commit;
    commitNoteRepoPath = repo;
    commitNoteText = "";
    commitNoteRemotes = [];
    commitNotePreferredRemote = selectedRemote;
    commitNoteLoading = true;
    commitNoteError = "";
    commitNoteStatus = "";

    const [noteResult, remotesResult] = await Promise.allSettled([
      getCommitNote(repo, commit.hash),
      listRemotes(repo),
    ]);
    if (commitNoteRepoPath !== repo || commitNoteTarget?.hash !== commit.hash) return;

    if (noteResult.status === "fulfilled") {
      commitNoteText = noteResult.value ?? "";
    } else {
      commitNoteError = errorToMessage(noteResult.reason);
    }
    if (remotesResult.status === "fulfilled") {
      commitNoteRemotes = remotesResult.value;
      commitNotePreferredRemote = remotesResult.value.some((remote) => remote.name === selectedRemote)
        ? selectedRemote
        : (remotesResult.value[0]?.name ?? "");
    } else if (!commitNoteError) {
      commitNoteError = errorToMessage(remotesResult.reason);
    }
    commitNoteLoading = false;
  }

  async function loadCommitNotePreview(commit: GitCommit): Promise<string | null> {
    if (!activeRepoPath) return null;
    return getCommitNote(activeRepoPath, commit.hash);
  }

  async function saveActiveCommitNote(note: string) {
    const commit = commitNoteTarget;
    const repo = commitNoteRepoPath;
    if (!commit || !repo || commitNoteBusy || !note.trim()) return;
    commitNoteBusy = true;
    commitNoteError = "";
    commitNoteStatus = "";
    try {
      await waitForBackgroundCommitNotes(repo);
      await setCommitNote(repo, commit.hash, note);
      commitNoteText = note;
      commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: true } : item);
      commitNoteStatus = appLanguage === "de"
        ? "Notiz gespeichert. Der Commit-Hash ist unverändert."
        : "Note saved. The commit hash is unchanged.";
      trackEvent("commit_note_saved");
    } catch (error) {
      commitNoteError = errorToMessage(error);
    } finally {
      commitNoteBusy = false;
    }
  }

  async function deleteActiveCommitNote() {
    const commit = commitNoteTarget;
    const repo = commitNoteRepoPath;
    if (!commit || !repo || commitNoteBusy) return;
    commitNoteBusy = true;
    commitNoteError = "";
    commitNoteStatus = "";
    try {
      await waitForBackgroundCommitNotes(repo);
      await deleteCommitNote(repo, commit.hash);
      commitNoteText = "";
      commits = commits.map((item) => item.hash === commit.hash ? { ...item, has_note: false } : item);
      commitNoteStatus = appLanguage === "de" ? "Notiz gelöscht." : "Note deleted.";
      trackEvent("commit_note_deleted");
    } catch (error) {
      commitNoteError = errorToMessage(error);
    } finally {
      commitNoteBusy = false;
    }
  }

  async function storedCredentialForNoteRemote(remote: string, direction: "fetch" | "push") {
    const config = commitNoteRemotes.find((item) => item.name === remote);
    const key = orgKeyFromUrl(direction === "push" ? (config?.push_url ?? "") : (config?.fetch_url ?? ""));
    return loadStoredCredential(key);
  }

  function commitNoteRemoteError(error: unknown): string {
    const raw = errorToMessage(error);
    if (!isAuthError(raw)) return stripAuthPrefix(raw);
    const detail = summarizeGitError(stripAuthPrefix(raw));
    return appLanguage === "de"
      ? `${detail || "Anmeldung fehlgeschlagen."} Bitte zuerst über Pull oder Push bei diesem Remote anmelden.`
      : `${detail || "Sign-in failed."} Sign in to this remote using Pull or Push first.`;
  }

  async function syncActiveCommitNotes(remote: string, direction: "fetch" | "push") {
    const commit = commitNoteTarget;
    const repo = commitNoteRepoPath;
    if (!commit || !repo || !remote || commitNoteBusy) return;
    commitNoteBusy = true;
    commitNoteError = "";
    commitNoteStatus = "";
    try {
      await waitForBackgroundCommitNotes(repo);
      const credential = await storedCredentialForNoteRemote(remote, direction);
      if (direction === "fetch") {
        await fetchCommitNotes(repo, remote, credential?.username, credential?.password);
        await refreshCommitHistory(repo);
        commitNoteText = (await getCommitNote(repo, commit.hash)) ?? "";
        commitNoteStatus = appLanguage === "de"
          ? `Notizen von ${remote} geladen und zusammengeführt.`
          : `Notes fetched from ${remote} and merged.`;
        trackEvent("commit_notes_fetched");
      } else {
        await pushCommitNotes(repo, remote, credential?.username, credential?.password);
        commitNoteStatus = appLanguage === "de"
          ? `Notizen zu ${remote} gesendet.`
          : `Notes pushed to ${remote}.`;
        trackEvent("commit_notes_pushed");
      }
    } catch (error) {
      commitNoteError = commitNoteRemoteError(error);
    } finally {
      commitNoteBusy = false;
    }
  }

  async function cherryPickFromCommit(commit: GitCommit) {
    if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return;
    await runOperation(`Cherry-picking ${commit.short_hash}`, async () => {
      applyStatus(await cherryPickCommit(activeRepoPath, commit.hash));
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("commit_cherry_picked");
    });
  }

  async function continueCherryPick() {
    if (!activeRepoPath || !cherryPickInProgress || hasConflicts) return;
    await runOperation("Continuing cherry-pick", async () => {
      applyStatus(await cherryPickContinue(activeRepoPath));
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("cherry_pick_continued");
    });
  }

  async function abortCherryPick() {
    if (!activeRepoPath || !cherryPickInProgress) return;
    const confirmed = window.confirm("Abort the current cherry-pick and return to the previous state?");
    if (!confirmed) return;

    await runOperation("Aborting cherry-pick", async () => {
      applyStatus(await cherryPickAbort(activeRepoPath));
      preparedResolutions = {};
      resolveDialogOpen = false;
      conflict = null;
      conflictTarget = "";
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("cherry_pick_aborted");
    });
  }

  function credentialModeFor(credential: StoredCredential): CredentialMode {
    if (credential.mode === "token" || credential.username === "oauth2") return "token";
    return "credentials";
  }

  // Resolve the keychain key (host/org) from the exact remote URL used by the
  // operation. Push URLs may intentionally differ from fetch URLs.
  async function currentCredKey(action: "push" | "pull" | "fetch" | "rename" | "delete" = "fetch"): Promise<string | null> {
    if (!activeRepoPath) return null;
    try {
      const remote = action === "rename" ? pendingRemoteRename?.remote : action === "delete" ? pendingRemoteDelete?.remote : selectedRemote;
      const url = await getRemoteUrl(activeRepoPath, remote || undefined, action === "push" || action === "rename" || action === "delete");
      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: CredentialAction,
    key?: string | null,
    credential?: StoredCredential | null,
  ) {
    if (!activeRepoPath && action !== "clone") return;
    credDialogError = "";
    credDialogAction = action;
    credDialogKey = key === undefined && action !== "clone"
      ? await currentCredKey(action)
      : (key ?? null);
    credDialogUsername = credential?.username === "oauth2" ? "" : (credential?.username ?? "");
    credDialogMode = credential ? credentialModeFor(credential) : "credentials";
    credDialogOpen = true;
    trackEvent("credential_dialog_opened", {
      action,
    });
  }

  // Post-process a pull/push result. Rejected credentials stay in the keychain
  // so a temporary 401/403 cannot erase a valid token; the key is only skipped
  // for the rest of this session until the user replaces it successfully.
  function handleRemoteResult(
    action: "push" | "pull" | "fetch" | "rename" | "delete",
    key: string | null,
    fromStore: boolean,
    username: string,
    mode: CredentialMode,
  ) {
    if (!errorMessage) {
      if (key) rejectedCredentialKeys.delete(key);
      credDialogOpen = false;
      credDialogAction = null;
      return;
    }
    const auth = isAuthError(errorMessage);
    const message = stripAuthPrefix(errorMessage);
    errorMessage = "";

    if (fromStore) {
      if (auth) {
        if (key) rejectedCredentialKeys.add(key);
        credDialogUsername = username === "oauth2" ? "" : username;
        credDialogMode = mode;
        const detail = summarizeGitError(message);
        credDialogError = detail
          ? `${detail} — please sign in again.`
          : "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 {
      if (auth && key) rejectedCredentialKeys.add(key);
      credDialogError = message || "Sign-in failed.";
    }
  }

  async function doActualPull(
    username: string,
    password: string,
    key: string | null,
    fromStore: boolean,
    mode: CredentialMode,
  ) {
    errorMessage = "";
    await runOperation("Pulling", async () => {
      applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("repository_pulled", {
        from_stored_credential: fromStore ? 1 : 0,
        changed_files: status?.files.length ?? 0,
      });
    });
    handleRemoteResult("pull", key, fromStore, username, mode);
  }

  async function doActualFetch(
    username: string,
    password: string,
    key: string | null,
    fromStore: boolean,
    mode: CredentialMode,
  ) {
    errorMessage = "";
    await runOperation("Fetching", async () => {
      applyStatus(await fetchRemote(activeRepoPath, username, password, remoteActionPrune, selectedRemote || undefined));
      remoteActionPrune = false;
      await refreshRefsAndCommitGraph(activeRepoPath);
      trackEvent("repository_fetched", {
        from_stored_credential: fromStore ? 1 : 0,
        ahead: status?.ahead ?? 0,
        behind: status?.behind ?? 0,
      });
    });
    handleRemoteResult("fetch", key, fromStore, username, mode);
  }

  async function doActualPush(
    username: string,
    password: string,
    key: string | null,
    fromStore: boolean,
    mode: CredentialMode,
  ) {
    errorMessage = "";
    await runOperation("Pushing", async () => {
      applyStatus(await push(activeRepoPath, username, password, remoteActionForceWithLease, selectedRemote || undefined));
      remoteActionForceWithLease = false;
      await refreshRepositoryViews(activeRepoPath, { files: false });
      trackEvent("repository_pushed", {
        from_stored_credential: fromStore ? 1 : 0,
        changed_files: status?.files.length ?? 0,
      });
    });

    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, pullStrategy, selectedRemote || undefined));
        await refreshRepositoryViews(activeRepoPath);
      });

      if (errorMessage) {
        handleRemoteResult("pull", key, fromStore, username, mode);
        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, false, selectedRemote || undefined));
        await refreshRepositoryViews(activeRepoPath, { files: false });
        trackEvent("repository_pushed_after_pull", {
          from_stored_credential: fromStore ? 1 : 0,
          changed_files: status?.files.length ?? 0,
        });
      });
    }

    handleRemoteResult("push", key, fromStore, username, mode);
  }

  async function doActualRemoteRename(
    username: string,
    password: string,
    key: string | null,
    fromStore: boolean,
    mode: CredentialMode,
  ) {
    const rename = pendingRemoteRename;
    if (!activeRepoPath || !rename) return;
    errorMessage = "";
    await runOperation(`Renaming ${rename.remote}/${rename.oldBranch} on remote`, async () => {
      applyStatus(await renameRemoteBranch(
        activeRepoPath,
        rename.remote,
        rename.oldBranch,
        rename.newBranch,
        username,
        password,
      ));
      renameBranchTarget = null;
      pendingRemoteRename = null;
      await refreshRefsAndCommitGraph(activeRepoPath);
      trackEvent("branch_renamed", { remote: 1 });
    });
    handleRemoteResult("rename", key, fromStore, username, mode);
  }

  async function doActualRemoteDelete(
    username: string,
    password: string,
    key: string | null,
    fromStore: boolean,
    mode: CredentialMode,
  ) {
    const deletion = pendingRemoteDelete;
    if (!activeRepoPath || !deletion) return;
    errorMessage = "";
    await runOperation(`Deleting ${deletion.label} from remote`, async () => {
      applyStatus(deletion.folder
        ? await deleteRemoteBranches(activeRepoPath, deletion.remote, deletion.branches, username, password)
        : await deleteRemoteBranch(activeRepoPath, deletion.remote, deletion.branches[0], username, password));
      deleteBranchTarget = null;
      pendingRemoteDelete = null;
      await refreshRefsAndCommitGraph(activeRepoPath);
      if (deletion.folder) {
        trackEvent("branch_folder_deleted", { attempted: deletion.branches.length, failed: 0, remote: 1 });
      } else {
        trackEvent("remote_branch_deleted");
      }
    });
    handleRemoteResult("delete", key, fromStore, username, mode);
  }

  async function handleCredentialSubmit(
    username: string,
    password: string,
    save: boolean,
    mode: CredentialMode,
  ) {
    const key = credDialogKey;
    // Honour "Save in keychain" immediately. A successful authentication
    // followed by an unrelated refresh/non-fast-forward error must not lose the
    // token and force the user to type it again on the next operation.
    if (save && key) {
      try {
        await credSave(key, username, password, mode);
      } catch (error) {
        credDialogError = errorToMessage(error);
        return;
      }
    }

    if (credDialogAction === "pull") await doActualPull(username, password, key, false, mode);
    else if (credDialogAction === "push") await doActualPush(username, password, key, false, mode);
    else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false, mode);
    else if (credDialogAction === "rename") await doActualRemoteRename(username, password, key, false, mode);
    else if (credDialogAction === "delete") await doActualRemoteDelete(username, password, key, false, mode);
    else if (credDialogAction === "clone" && pendingClone) {
      await cloneRepo(
        pendingClone.remoteUrl,
        pendingClone.parentPath,
        pendingClone.directoryName,
        username,
        password,
        key,
        false,
        mode,
      );
    }
  }

  async function startRemoteAction(action: "push" | "pull" | "fetch") {
    if (!activeRepoPath) return;
    trackEvent("remote_action_started", {
      action,
    });
    const key = await currentCredKey(action);
    const stored = await loadStoredCredential(key);

    if (stored && (!key || !rejectedCredentialKeys.has(key))) {
      const mode = credentialModeFor(stored);
      if (action === "pull") await doActualPull(stored.username, stored.password, key, true, mode);
      else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true, mode);
      else await doActualPush(stored.username, stored.password, key, true, mode);
      return;
    }

    await openCredentialDialog(action, key, stored);
  }

  async function fetchRepo() {
    await startRemoteAction("fetch");
  }

  async function pullRepo() {
    await startRemoteAction("pull");
  }

  async function pushRepo() {
    await startRemoteAction("push");
  }

  async function deleteTrackedRemoteBranch(branch: GitBranchInfo) {
    if (!activeRepoPath || !branch.remote) return;
    if (import.meta.env.DEV) console.info("[Gitty remote] remote branch delete requested", branch);
    deleteBranchTarget = branch;
    deleteBranchForce = false;
    trackEvent("remote_branch_delete_dialog_opened");
  }

  async function initializeRepository() {
    const selected = await openDialog({ directory: true, multiple: false, title: "Choose an empty or existing folder" });
    if (typeof selected !== "string") return;
    const branch = window.prompt("Initial branch name", "main")?.trim(); if (!branch) return;
    await runOperation("Initializing repository", async () => { await initRepository(selected, branch); await openRepo(selected); });
  }

  async function revertHistoryCommit(commit: GitCommit) {
    if (!activeRepoPath || !window.confirm(`Revert commit ${commit.short_hash} (${commit.summary}) with a new commit?`)) return;
    await runOperation(`Reverting ${commit.short_hash}`, async () => {
      applyStatus(await revertCommit(activeRepoPath, commit.hash));
      await refreshRepositoryViews(activeRepoPath, { branches: false });
    });
  }

  async function continueMerge() {
    if (!activeRepoPath) return;
    await runOperation("Continuing merge", async () => {
      applyStatus(await mergeContinue(activeRepoPath));
      await refreshRepositoryViews(activeRepoPath);
    });
  }

  async function abortMerge() {
    if (!activeRepoPath || !window.confirm("Abort the current merge and restore the pre-merge state?")) return;
    await runOperation("Aborting merge", async () => {
      applyStatus(await mergeAbort(activeRepoPath));
      await refreshRepositoryViews(activeRepoPath);
    });
  }

  async function fetchPruneRepo() {
    remoteActionPrune = true;
    await startRemoteAction("fetch");
  }

  async function forcePushRepo() {
    if (!window.confirm("Push the current branch with --force-with-lease? This is intended for a branch whose history you rebased.")) return;
    remoteActionForceWithLease = true;
    await startRemoteAction("push");
  }

  async function openSyncOptions() {
    if (!activeRepoPath) return;
    try {
      syncSettingsRemotes = await listRemotes(activeRepoPath);
      syncSettingsOpen = true;
    } catch (error) { errorMessage = errorToMessage(error); }
  }

  async function saveSyncSettings(strategy: PullStrategy, remote: string, upstream: string) {
    if (!activeRepoPath || !status?.current_branch) return;
    await runOperation("Saving sync settings", async () => {
      pullStrategy = strategy; selectedRemote = remote;
      localStorage.setItem("gitlite.pullStrategy", strategy); localStorage.setItem("gitlite.selectedRemote", remote);
      applyStatus(await setBranchUpstream(activeRepoPath, status!.current_branch!, upstream || undefined));
      await refreshBranchList(activeRepoPath); syncSettingsOpen = false;
    });
  }

  async function addSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await addRemote(activeRepoPath, name, url); await refreshBranchList(activeRepoPath); }
  async function updateSyncRemote(name: string, url: string) { if (!activeRepoPath) return; syncSettingsRemotes = await updateRemote(activeRepoPath, name, url); }
  async function removeSyncRemote(name: string) {
    if (!activeRepoPath) return;
    operation = `Removing remote ${name}`;
    try {
      syncSettingsRemotes = await removeRemote(activeRepoPath, name);
      if (syncSettingsRemotes.some((remote) => remote.name === name)) throw new Error(`Remote '${name}' still exists after removal.`);
      if (selectedRemote === name) { selectedRemote = ""; localStorage.setItem("gitlite.selectedRemote", ""); }
      applyStatus(await getStatus(activeRepoPath));
      await refreshBranchList(activeRepoPath);
    } catch (error) {
      const message = errorToMessage(error);
      if (import.meta.env.DEV) console.error("[Gitty remote] remove_remote failed", { name, path: activeRepoPath, error });
      throw new Error(message);
    } finally {
      operation = "";
    }
  }

  async function saveStash(message: string, includeUntracked: boolean) {
    if (!activeRepoPath || changedFiles.length === 0) return;
    const stashedFiles = changedFiles.length;
    await runOperation("Stashing changes", async () => {
      applyStatus(await stashPush(activeRepoPath, message, includeUntracked));
      await refreshRepositoryViews(activeRepoPath, {
        branches: false,
        stashes: true,
        commits: false,
      });
      trackEvent("stash_saved", {
        include_untracked: includeUntracked ? 1 : 0,
        changed_files: stashedFiles,
      });
    });
  }

  async function applyStashEntry(stash: GitStash) {
    if (!activeRepoPath) return;
    await runOperation(`Applying ${stash.selector}`, async () => {
      applyStatus(await stashApply(activeRepoPath, stash.selector));
      await refreshRepositoryViews(activeRepoPath, {
        branches: false,
        stashes: true,
        commits: false,
      });
      trackEvent("stash_applied");
    });
  }

  async function popStashEntry(stash: GitStash) {
    if (!activeRepoPath) return;
    await runOperation(`Popping ${stash.selector}`, async () => {
      applyStatus(await stashPop(activeRepoPath, stash.selector));
      await refreshRepositoryViews(activeRepoPath, {
        branches: false,
        stashes: true,
        commits: false,
      });
      trackEvent("stash_popped");
    });
  }

  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);
      trackEvent("stash_dropped");
    });
  }

  // ── File staging / restore ─────────────────────────────────────────────────

  async function stageFile(files: GitFileStatus[]) {
    const targets = files.filter((file) => file.unstaged !== null);
    if (targets.length === 0) return;
    const paths = targets.map((file) => file.path);
    await runOperation(targets.length === 1 ? `Staging ${baseName(targets[0].path)}` : `Staging ${targets.length} files`, async () => {
      applyStatus(await stageFiles(activeRepoPath, paths));
      await refreshExplorerFiles(activeRepoPath);
      trackEvent("file_staged", {
        files: targets.length,
        status: targets.length === 1 ? (targets[0].unstaged ?? targets[0].staged ?? "unknown") : "multiple",
      });
    });
  }

  async function unstageFile(files: GitFileStatus[]) {
    const targets = files.filter((file) => file.staged !== null);
    if (targets.length === 0) return;
    const paths = targets.map((file) => file.path);
    await runOperation(targets.length === 1 ? `Unstaging ${baseName(targets[0].path)}` : `Unstaging ${targets.length} files`, async () => {
      applyStatus(await unstageFiles(activeRepoPath, paths));
      await refreshExplorerFiles(activeRepoPath);
      trackEvent("file_unstaged", {
        files: targets.length,
        status: targets.length === 1 ? (targets[0].staged ?? targets[0].unstaged ?? "unknown") : "multiple",
      });
    });
  }

  function discardFiles(files: GitFileStatus[], staged: boolean) {
    if (!activeRepoPath || isBusy || files.length === 0) return;
    pendingDiscard = { kind: "file", files, staged };
    trackEvent("discard_confirm_opened", {
      kind: "file",
      staged: staged ? 1 : 0,
      files: files.length,
    });
  }

  function discardChanges(files: GitFileStatus[]) {
    if (!activeRepoPath || isBusy || files.length === 0) return;
    pendingDiscard = { kind: "all-changes", files };
    trackEvent("discard_confirm_opened", {
      kind: "all",
      files: files.length,
    });
  }

  async function runDiscardFiles(files: GitFileStatus[], staged: boolean) {
    if (files.length === 0) return;
    const paths = files.map((file) => file.path);
    await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
      applyStatus(await restoreFiles(activeRepoPath, paths, staged));
      await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
      trackEvent("file_discarded", {
        files: files.length,
        staged: staged ? 1 : 0,
      });
    });
  }

  // Discards both the staged and unstaged changes for each given file (used
  // by "Discard all" and "Discard selected", which don't distinguish lanes).
  async function runDiscardAllChanges(files: GitFileStatus[]) {
    const stagedPaths = files.filter((file) => file.staged !== null).map((file) => file.path);
    const unstagedPaths = files.filter((file) => file.unstaged !== null).map((file) => file.path);
    if (stagedPaths.length === 0 && unstagedPaths.length === 0) return;
    await runOperation(files.length === 1 ? `Discarding ${baseName(files[0].path)}` : `Discarding ${files.length} files`, async () => {
      let nextStatus: GitStatus | null = null;
      if (stagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, stagedPaths, true);
      if (unstagedPaths.length > 0) nextStatus = await restoreFiles(activeRepoPath, unstagedPaths, false);
      if (nextStatus) applyStatus(nextStatus);
      await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
      trackEvent("file_discarded", {
        files: files.length,
        staged: 2,
      });
    });
  }

  async function openLinePatch(file: GitFileStatus, staged: boolean) {
    if (!activeRepoPath) return;
    linePatchOpen = true;
    linePatchFile = file;
    linePatchStaged = staged;
    linePatchText = "";
    linePatchError = "";
    linePatchLoading = true;
    trackEvent("line_patch_opened", {
      staged: staged ? 1 : 0,
    });

    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 = "";
  }

  async function openBlame(node: ExplorerNode) {
    if (!activeRepoPath || node.kind !== "file") return;
    blameOpen = true;
    blameFilePath = node.path;
    blameLines = [];
    blameError = "";
    blameLoading = true;

    try {
      const result = await getFileBlame(activeRepoPath, node.path);
      blameLines = result.lines;
      trackEvent("blame_opened", { lines: result.lines.length });
    } catch (error) {
      blameError = errorToMessage(error);
      errorMessage = blameError;
    } finally {
      blameLoading = false;
    }
  }

  function closeBlame() {
    if (isBusy) return;
    blameOpen = false;
    blameFilePath = "";
    blameLines = [];
    blameError = "";
  }

  function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus, scope: "hunk" | "lines"): string {
    const target = scope === "lines" ? "selected lines" : "hunk";
    switch (action) {
      case "stage":
        return `Staging ${target} in ${file.path}`;
      case "unstage":
        return `Unstaging ${target} in ${file.path}`;
      default:
        return `Discarding ${target} 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,
    scope: "hunk" | "lines",
  ) {
    if (!activeRepoPath || isBusy) return;
    operation = patchOperationLabel(action, file, scope);
    errorMessage = "";
    linePatchError = "";

    try {
      applyStatus(await applyFilePatch(activeRepoPath, file.path, patch, action));
      await refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });

      const updatedPatch = await getFilePatch(activeRepoPath, file.path, staged);
      if (updatedPatch.trim()) {
        linePatchText = updatedPatch;
      } else {
        linePatchOpen = false;
        linePatchFile = null;
        linePatchText = "";
      }
      trackEvent("line_patch_applied", {
        action,
      });
    } catch (error) {
      linePatchError = errorToMessage(error);
      errorMessage = linePatchError;
    } finally {
      operation = "";
    }
  }

  async function applyLinePatch(action: PatchApplyAction, patch: string, scope: "hunk" | "lines") {
    if (!activeRepoPath || !linePatchFile || isBusy) return;
    const file = linePatchFile;
    const staged = linePatchStaged;

    if (isDiscardPatchAction(action)) {
      pendingDiscard = { kind: "patch", file, staged, action, patch, scope };
      trackEvent("discard_confirm_opened", {
        kind: scope,
        staged: staged ? 1 : 0,
      });
      return;
    }

    await runLinePatchAction(action, patch, file, staged, scope);
  }

  async function confirmDiscard() {
    const discard = pendingDiscard;
    if (!discard || !activeRepoPath || isBusy) return;

    if (discard.kind === "file") {
      await runDiscardFiles(discard.files, discard.staged);
    } else if (discard.kind === "all-changes") {
      await runDiscardAllChanges(discard.files);
    } else {
      await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged, discard.scope);
    }

    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);
      trackEvent("all_files_staged", {
        files: paths.length,
      });
    });
  }

  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);
      trackEvent("all_files_unstaged", {
        files: paths.length,
      });
    });
  }

  async function commitChanges() {
    const message = commitMessage.trim();
    if (!activeRepoPath) return;
    if (!amendMode && !message) return;
    if (hasConflicts) {
      errorMessage = "Resolve all conflicts before committing.";
      return;
    }
    if (rebaseInProgress) {
      errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase.";
      return;
    }
    if (cherryPickInProgress) {
      errorMessage = "A cherry-pick is in progress. Use Cherry-pick continue or abort it.";
      return;
    }

    if (amendMode) {
      await runOperation("Amending", async () => {
        applyStatus(await amendCommit(activeRepoPath, message));
        commitMessage = "";
        amendMode = false;
        preAmendDraftMessage = "";
        lastLocalAiGeneratedMessage = "";
        await refreshRepositoryViews(activeRepoPath);
        trackEvent("commit_created", { amend: 1 });
      });
      return;
    }

    const trackedStagedCount = stagedCount;
    await runOperation("Committing", async () => {
      applyStatus(await commit(activeRepoPath, message));
      commitMessage = "";
      lastLocalAiGeneratedMessage = "";
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("commit_created", { amend: 0, staged_files: trackedStagedCount });
    });
  }

  // Only offered when the last commit hasn't reached a remote yet (no upstream,
  // or the branch is ahead of it) — amending/undoing a pushed commit rewrites
  // history other clones already have, which needs a force-push to reconcile.
  async function toggleAmendMode(checked: boolean) {
    if (!activeRepoPath || isBusy) return;
    if (!checked) {
      amendMode = false;
      commitMessage = preAmendDraftMessage;
      preAmendDraftMessage = "";
      return;
    }
    if (!canAmend) return;

    try {
      const message = await lastCommitMessage(activeRepoPath);
      preAmendDraftMessage = commitMessage;
      commitMessage = message ?? "";
      amendMode = true;
    } catch (error) {
      errorMessage = errorToMessage(error);
    }
  }

  async function undoLastCommitChange() {
    if (!activeRepoPath || !canAmend || isBusy) return;
    const confirmed = window.confirm(
      "Undo the last commit?\n\nIts changes come back as uncommitted changes in the working tree — nothing is discarded.",
    );
    if (!confirmed) return;

    await runOperation("Undoing last commit", async () => {
      applyStatus(await undoLastCommit(activeRepoPath));
      if (amendMode) {
        amendMode = false;
        commitMessage = preAmendDraftMessage;
        preAmendDraftMessage = "";
      }
      await refreshRepositoryViews(activeRepoPath);
      trackEvent("commit_undone");
    });
  }

  // ── 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 refreshRepositoryViews(activeRepoPath);
      trackEvent("commit_restored");
    });
  }

  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 refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
      trackEvent("commit_file_restored");
    });
    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;
      comparisonFromLabel = "";
      comparisonToLabel = "";
      selectedDiffPath = matchingFile?.path ?? result.files[0]?.path ?? file.path;
      pendingRestoreFile = { commit: target, file };
      compareDialogOpen = true;
      trackEvent("diff_opened", {
        source: "commit_file",
        files: result.files.length,
      });
    });
  }

  // ── Explorer interaction ───────────────────────────────────────────────────

  function toggleExplorerFolder(node: ExplorerNode) {
    if (node.kind !== "folder") return;
    const next = new Set(expandedExplorerPaths);
    const wasExpanded = next.has(node.path);
    if (wasExpanded) next.delete(node.path); else next.add(node.path);
    expandedExplorerPaths = next;
    trackEvent("explorer_folder_toggled", {
      expanded: wasExpanded ? 0 : 1,
      expanded_folders: expandedExplorerPaths.size,
    });
  }

  function expandAllExplorerFolders() {
    expandedExplorerPaths = allExplorerFolderPaths(repoFiles);
    trackEvent("explorer_folders_expanded", {
      folders: expandedExplorerPaths.size,
    });
  }

  function collapseAllExplorerFolders() {
    expandedExplorerPaths = new Set();
    trackEvent("explorer_folders_collapsed");
  }

  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 without blocking the rest of the UI. A request id guards
  // against a slower, stale request overwriting a newer file selection.
  async function loadSelectedFileHistory(path: string, repo = activeRepoPath) {
    await refreshFileHistory(repo, path, true);
  }

  function openFileHistoryDialog(node: ExplorerNode) {
    if (!activeRepoPath || node.kind !== "file" || !node.tracked) return;
    selectedExplorerPath = node.path;
    selectedExplorerKind = "file";
    fileHistoryDialogOpen = true;
    fileHistoryError = "";
    void loadSelectedFileHistory(node.path);
    trackEvent("file_history_opened", {
      source: "explorer_context_menu",
    });
  }

  function closeFileHistoryDialog() {
    fileHistoryRequestId += 1;
    cancelActiveFileHistoryLoad();
    activeFileHistoryRequestId = "";
    fileHistoryLoading = false;
    fileHistoryError = "";
    fileHistoryDialogOpen = false;
  }

  async function selectExplorerNode(node: ExplorerNode) {
    if (!activeRepoPath) return;
    selectedExplorerPath = node.path;
    selectedExplorerKind = node.kind;
    trackEvent("explorer_node_selected", {
      kind: node.kind,
      tracked: node.tracked ? 1 : 0,
    });
  }

  async function selectFileFromSearch(file: GitRepositoryFile) {
    if (!activeRepoPath) return;
    selectedExplorerPath = file.path;
    selectedExplorerKind = "file";
    expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);

    void loadSelectedFileHistory(file.path);
    trackEvent("explorer_file_selected", {
      source: "search",
      tracked: file.tracked ? 1 : 0,
    });
  }

  function selectFileFromStatus(file: GitFileStatus) {
    if (!activeRepoPath) return;
    selectedExplorerPath = file.path;
    selectedExplorerKind = "file";
    expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
    hideAndResetFileHistory();
    trackEvent("explorer_file_selected", {
      source: "status",
      status: file.unstaged ?? file.staged ?? "unknown",
    });
  }

  async function openFileFromExplorer(node: ExplorerNode) {
    if (!activeRepoPath || node.kind !== "file") return;
    selectedExplorerPath = node.path;
    selectedExplorerKind = "file";

    try {
      await openRepositoryFile(activeRepoPath, node.path);
      trackEvent("explorer_file_opened", {
        tracked: node.tracked ? 1 : 0,
      });
    } catch (error) {
      errorMessage = errorToMessage(error);
    }
  }

  async function openExternalFileDiff(filePath: string, scope: ExternalDiffScope, source: "status" | "explorer" | "diff-dialog") {
    if (!activeRepoPath || !filePath || isBusy) return;
    await runOperation(`Opening ${filePath} in ${diffToolName}`, async () => {
      await launchExternalDiff(activeRepoPath, filePath, externalToolsSettings.diff, scope);
      trackEvent("external_tool_opened", { kind: "diff", scope: source });
    });
  }

  async function openPreferredFileDiff(file: GitFileStatus, staged: boolean) {
    if (externalToolsSettings.diffOpenMode === "external") {
      await openExternalFileDiff(file.path, staged ? "staged" : "unstaged", "status");
      return;
    }
    await openLinePatch(file, staged);
  }

  async function openCurrentLinePatchExternally() {
    if (!linePatchFile) return;
    await openExternalFileDiff(linePatchFile.path, linePatchStaged ? "staged" : "unstaged", "diff-dialog");
  }

  function loadExternalToolsSettings(): ExternalToolsSettings {
    try {
      return normaliseExternalToolsSettings(JSON.parse(localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) ?? "null"));
    } catch {
      return defaultExternalToolsSettings();
    }
  }

  function hasStoredExternalToolsSettings(): boolean {
    try {
      return localStorage.getItem(EXTERNAL_TOOLS_SETTINGS_KEY) != null;
    } catch {
      return false;
    }
  }

  function persistExternalToolsSettings(next: ExternalToolsSettings) {
    try {
      localStorage.setItem(EXTERNAL_TOOLS_SETTINGS_KEY, JSON.stringify(next));
    } catch {
      // Local storage is best-effort only; built-in defaults remain usable.
    }
  }

  async function refreshDetectedExternalTools(applyDetectedDefaults = true) {
    externalToolsDetectionPending = true;
    externalToolsDetectionUnavailable = false;
    try {
      detectedExternalTools = await detectExternalTools();
      if (applyDetectedDefaults) {
        externalToolsSettings = externalToolsConfigured
          ? resolveDetectedExternalToolPrograms(externalToolsSettings, detectedExternalTools)
          : defaultExternalToolsSettings(detectedExternalTools);
      }
    } catch {
      detectedExternalTools = [];
      externalToolsDetectionUnavailable = true;
    } finally {
      externalToolsDetectionPending = false;
    }
  }

  async function openActiveRepoInEditor() {
    if (!activeRepoPath || isBusy) return;
    try {
      await launchExternalTool(activeRepoPath, externalToolsSettings.editor);
      trackEvent("external_tool_opened", { kind: "editor", scope: "repository" });
    } catch (error) { errorMessage = errorToMessage(error); }
  }

  async function openActiveRepoTerminal() {
    if (!activeRepoPath || isBusy) return;
    try { await launchExternalTool(activeRepoPath, externalToolsSettings.terminal); trackEvent("external_tool_opened", { kind: "terminal" }); }
    catch (error) { errorMessage = errorToMessage(error); }
  }

  async function openActiveRepoFileManager() {
    if (!activeRepoPath || isBusy) return;
    try { await launchExternalTool(activeRepoPath, externalToolsSettings.fileManager); trackEvent("external_tool_opened", { kind: "file_manager" }); }
    catch { await openActiveRepoInExplorer(); }
  }

  async function openExplorerFileInEditor(node: ExplorerNode) {
    if (!activeRepoPath || node.kind !== "file" || isBusy) return;
    try {
      await launchExternalTool(activeRepoPath, externalToolsSettings.editor, node.path);
      trackEvent("external_tool_opened", { kind: "editor", scope: "file" });
    } catch (error) {
      errorMessage = errorToMessage(error);
    }
  }

  async function compareExplorerFileExternally(node: ExplorerNode) {
    if (!activeRepoPath || node.kind !== "file" || !node.tracked || isBusy) return;
    await openExternalFileDiff(node.path, "head", "explorer");
  }

  async function openFileFromCommandPalette(file: GitRepositoryFile) {
    if (!activeRepoPath) return;
    selectedExplorerPath = file.path;
    selectedExplorerKind = "file";
    expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
    try {
      await openRepositoryFile(activeRepoPath, file.path);
      trackEvent("explorer_file_opened", { source: "command_palette", tracked: file.tracked ? 1 : 0 });
    } catch (error) {
      errorMessage = errorToMessage(error);
    }
  }

  function selectCommitFromCommandPalette(target: GitCommit) {
    selectedCommitHash = target.hash;
    trackEvent("commit_selected", { source: "command_palette" });
  }

  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 refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });
      trackEvent("selected_file_restored_from_commit", {
        kind,
      });
    });
  }

  // ── Compare ────────────────────────────────────────────────────────────────

  function compareRefForBranch(branch: GitBranchInfo): string {
    return branch.remote ? `refs/remotes/${branch.name}` : `refs/heads/${branch.name}`;
  }

  function compareLabelForTarget(target: string): string {
    const branch = branches.find((candidate) => compareRefForBranch(candidate) === target);
    if (branch) return branch.name;
    return commits.find((commit) => commit.hash === target)?.short_hash ?? "";
  }

  function openCompareSelect() {
    if (!hasRepository) return;
    if (!compareFrom) {
      const current = branches.find((branch) => branch.current);
      if (current) compareFrom = compareRefForBranch(current);
    }
    compareSelectOpen = true;
    trackEvent("compare_opened");
  }

  function compareBranchWithCurrent(branch: GitBranchInfo) {
    if (!hasRepository) return;
    const selected = compareRefForBranch(branch);
    const current = branches.find((candidate) => candidate.current);
    compareFrom = current ? compareRefForBranch(current) : "";
    compareTo = selected === compareFrom ? "" : selected;
    compareSelectOpen = true;
    trackEvent("compare_opened", { source: "branch_context", remote: branch.remote ? 1 : 0 });
  }

  function openGlobalSearchDialog() {
    globalSearchOpen = true;
    trackEvent("global_search_opened");
  }

  function openHelp() {
    helpOpen = true;
    trackEvent("help_opened");
  }

  function openAppSettings() {
    appSettingsOpen = true;
  }

  function openAiSettings() {
    aiSettingsOpen = true;
  }

  async function compareSelectedTargets() {
    if (!canCompare) return;
    await runOperation("Comparing revisions", async () => {
      const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
      comparison = result;
      comparisonFromLabel = compareLabelForTarget(compareFrom);
      comparisonToLabel = compareLabelForTarget(compareTo);
      selectedDiffPath = result.files[0]?.path ?? "";
      diffHighlightQuery = "";
      pendingRestoreFile = null;
      compareSelectOpen = false;
      compareDialogOpen = true;
      trackEvent("compare_completed", {
        files: result.files.length,
      });
    });
  }

  async function diffSelectedFileFromCommit(historyCommit: GitCommit) {
    if (!activeRepoPath || !selectedExplorerPath) return;
    await runOperation(`Diffing ${selectedExplorerPath}`, async () => {
      const result = await diffFileAgainstWorkingTree(activeRepoPath, historyCommit.hash, selectedExplorerPath);
      comparison = result;
      comparisonFromLabel = "";
      comparisonToLabel = "";
      selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath;
      diffHighlightQuery = "";
      pendingRestoreFile = null;
      compareDialogOpen = true;
      trackEvent("diff_opened", {
        source: "file_history",
        files: result.files.length,
      });
    });
  }

  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;
      comparisonFromLabel = "";
      comparisonToLabel = "";
      selectedDiffPath = result.files[0]?.path ?? hit.file;
      diffHighlightQuery = lastSearchQuery;
      pendingRestoreFile = null;
      compareDialogOpen = true;
      trackEvent("diff_opened", {
        source: "search_hit",
        files: result.files.length,
      });
    });
  }

  function closeCompareDialog() {
    compareDialogOpen = false;
    pendingRestoreFile = null;
    trackEvent("compare_closed");
  }

  async function restorePreviewedCommitFile() {
    if (!pendingRestoreFile) return;
    const restored = await restoreCommitFile(pendingRestoreFile.commit, pendingRestoreFile.file);
    if (restored) closeCompareDialog();
  }

  function selectDiffFile(file: GitDiffFile) {
    selectedDiffPath = file.path;
    trackEvent("diff_file_selected", {
      additions: file.additions,
      deletions: file.deletions,
    });
  }

  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;
        trackEvent("global_search_completed", { results: results.length, case_sensitive: caseSensitive ? 1 : 0 });
      }
    } 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;
    const openExternally = externalToolsSettings.mergeOpenMode === "external";
    await runOperation(openExternally ? `Opening ${first} in ${mergeToolName}` : "Loading conflicts", async () => {
      preparedResolutions = {};
      resolveDialogOpen = true;
      await loadConflict(first);
      trackEvent("resolve_dialog_opened", {
        conflicts: conflictedFiles.length,
      });
      if (openExternally) await launchMergeToolForConflict(first);
    });
  }

  async function selectConflictFile(path: string) {
    if (path === conflictTarget || isBusy) return;
    const openExternally = externalToolsSettings.mergeOpenMode === "external";
    await runOperation(openExternally ? `Opening ${path} in ${mergeToolName}` : `Loading ${path}`, async () => {
      await loadConflict(path);
      trackEvent("conflict_file_selected");
      if (openExternally) await launchMergeToolForConflict(path);
    });
  }

  async function launchMergeToolForConflict(path: string) {
    if (!activeRepoPath || !path) return;
    await launchExternalMerge(activeRepoPath, path, externalToolsSettings.merge);
    await loadConflict(path);
    await refreshExplorerFiles(activeRepoPath);
    trackEvent("external_tool_opened", { kind: "merge", scope: "conflict" });
  }

  async function openConflictInExternalMerge(path: string) {
    if (!activeRepoPath || !path || isBusy) return;
    await runOperation(`Opening ${path} in ${mergeToolName}`, async () => {
      await launchMergeToolForConflict(path);
    });
  }

  async function handleMarkResolved(path: string, resolution: PreparedResolution) {
    preparedResolutions = { ...preparedResolutions, [path]: resolution };
    trackEvent("conflict_resolution_prepared", {
      kind: resolution.kind,
      prepared_files: Object.keys(preparedResolutions).length,
    });

    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 refreshRepositoryViews(activeRepoPath, { branches: false, commits: false });

      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);
      }
      trackEvent("conflicts_resolved", {
        files: entries.length,
        remaining: remaining.length,
      });
    });
  }

  // ── Event handlers ─────────────────────────────────────────────────────────

  function handleWindowKeydown(event: KeyboardEvent) {
    if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
      event.preventDefault();
      if (!event.repeat) commandPaletteOpen = !commandPaletteOpen;
      return;
    }
    if ((event.ctrlKey || event.metaKey) && event.key === "/") {
      event.preventDefault();
      openHelp();
      return;
    }
    if (event.key === "Escape" && commandPaletteOpen) {
      commandPaletteOpen = false;
      return;
    }
    if (event.key === "Escape" && helpOpen) {
      helpOpen = false;
      return;
    }
    if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
    else if (event.key === "Escape" && fileHistoryDialogOpen && !isBusy) closeFileHistoryDialog();
    else if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
    else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
    else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
    else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
    else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog();
    else if (event.key === "Escape" && worktreeDialogOpen) closeWorktreeDialog();
    else if (event.key === "Escape" && gitLfsDialogOpen) closeGitLfsDialog();
    else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
    else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
    else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
    else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
  }

  function handleWindowContextMenu(event: MouseEvent) {
    if (import.meta.env.DEV) return;
    event.preventDefault();
    if (repoTabContextMenu) closeRepoTabContextMenu();
  }

  function handleWindowClick() {
    if (repoTabContextMenu) closeRepoTabContextMenu();
  }
</script>

<svelte:head>
  <title>Gitty</title>
</svelte:head>

<svelte:window on:click={handleWindowClick} on:keydown={handleWindowKeydown} on:contextmenu|capture={handleWindowContextMenu} />

<main class="shell">
  <TitleBar
    onOpenSettings={openAppSettings}
    onOpenHelp={openHelp}
    language={appLanguage}
  />

  <div class="shell-body">

    <RepoTabs
      {activeView}
      {repoTabs}
      {isBusy}
      language={appLanguage}
      onOpenManagement={openRepoManagement}
      isActive={(path) => activeView === "repository" && sameRepoPath(activeRepoPath, path)}
      onSelect={selectRepoTab}
      onClose={closeRepoTab}
      onContextMenu={openRepoTabContextMenu}
      onAdd={chooseRepositoryFolder}
    />

    <!-- Repo actions live under the tab bar and disappear in Repository
         Management, where none of them are applicable. -->
    {#if workspaceActive}
      <RepoToolbar
        hasRepository={workspaceActive}
        {isBusy}
        {operation}
        ahead={status?.ahead ?? 0}
        behind={status?.behind ?? 0}
        localOnly={currentBranchIsLocalOnly}
        language={appLanguage}
        editorName={editorToolName}
        terminalName={terminalToolName}
        fileManagerName={fileManagerToolName}
        onFetch={fetchRepo}
        onPull={pullRepo}
        onPush={pushRepo}
        onRefresh={refreshRepo}
        onSearch={openGlobalSearchDialog}
        onCompare={openCompareSelect}
        onInteractiveRebase={openInteractiveRebase}
        onReflog={openReflog}
        onOpenInEditor={openActiveRepoInEditor}
        onOpenTerminal={openActiveRepoTerminal}
        onOpenInExplorer={openActiveRepoFileManager}
        onFetchPrune={fetchPruneRepo}
        onForcePush={forcePushRepo}
        onSyncOptions={openSyncOptions}
        onOpenLfs={openGitLfsDialog}
      />
    {/if}

    {#if repoTabContextMenu}
      {@const menu = repoTabContextMenu}
      {@const contextTab = repoTabs.find((tab) => sameRepoPath(tab.path, menu.path))}
      {@const contextIndex = repoTabIndex(menu.path)}
      {#if contextTab}
        <div
          class="repo-tab-context-menu"
          style={`left: ${menu.x}px; top: ${menu.y}px;`}
          role="menu"
          tabindex="-1"
          aria-label={`Tab actions for ${contextTab.name}`}
          oncontextmenu={(event) => { event.preventDefault(); event.stopPropagation(); }}
        >
          <button type="button" role="menuitem" onclick={() => closeRepoTabFromContext(contextTab.path)} disabled={isBusy}>
            Close tab
          </button>
          <div class="menu-separator" aria-hidden="true"></div>
          <button type="button" role="menuitem" onclick={() => closeOtherRepoTabs(contextTab.path)} disabled={isBusy || repoTabs.length <= 1}>
            Close other tabs
          </button>
          <button type="button" role="menuitem" onclick={() => closeRepoTabsToRight(contextTab.path)} disabled={isBusy || contextIndex < 0 || contextIndex >= repoTabs.length - 1}>
            Close tabs to the right
          </button>
        </div>
      {/if}
    {/if}

    <!-- Global error toast -->
    {#if errorMessage}
      {#key errorMessage}
        <div class="error-toast-region" aria-live="assertive" aria-atomic="true">
          <section
            class="error-toast"
            role="alert"
            onmouseenter={() => pauseAutoHideError("errorMessage")}
            onmouseleave={() => resumeAutoHideError("errorMessage")}
          >
            <div class="error-toast-icon">
              <AlertCircle size={20} aria-hidden="true" />
            </div>
            <div class="error-toast-content">
              <strong>{appLanguage === "de" ? "Etwas ist schiefgelaufen" : "Something went wrong"}</strong>
              <span>{errorMessage}</span>
            </div>
            <button
              class="error-toast-close"
              type="button"
              onclick={() => { errorMessage = ""; }}
              title={appLanguage === "de" ? "Schließen" : "Close"}
              aria-label={appLanguage === "de" ? "Fehlermeldung schließen" : "Dismiss error"}
            >
              <X size={15} aria-hidden="true" />
            </button>
            <div class="error-toast-timeout" aria-hidden="true"></div>
          </section>
        </div>
      {/key}
    {/if}

    <!-- Status notices -->
    {#if operation && operation !== "Opening repository" && !hasRepository}
      <section class="notice busy" aria-live="polite">
        <LoaderCircle class="spin" size={17} aria-hidden="true" />
        <span>{operation}</span>
      </section>
    {/if}

    {#if workspaceActive && mergeInProgress}
      <section class="notice conflict" role="status">
        <GitMerge size={17} aria-hidden="true" />
        <span>{hasConflicts ? "Merge in progress. Resolve all conflicts, then continue." : "Merge is ready to be completed."}</span>
        {#if hasConflicts}<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>{/if}
        <button type="button" onclick={continueMerge} disabled={isBusy || hasConflicts}>Continue</button>
        <button type="button" onclick={abortMerge} disabled={isBusy}>Abort</button>
      </section>
    {:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
      <section class="notice conflict" role="alert">
        <GitMerge size={17} aria-hidden="true" />
        <span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
        <button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
      </section>
    {/if}

    {#if workspaceActive && rebaseInProgress}
      <section class="notice rebase" role="status">
        <GitBranch size={17} aria-hidden="true" />
        <span>
          Rebase in progress.
          {#if hasConflicts}
            Resolve conflicts, then continue.
          {:else}
            Continue when the index is ready, or abort to return to the previous state.
          {/if}
        </span>
        {#if hasConflicts}
          <button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
        {/if}
        <button type="button" onclick={continueRebase} disabled={isBusy || hasConflicts}>Continue</button>
        <button type="button" onclick={abortRebase} disabled={isBusy}>Abort</button>
      </section>
    {/if}

    {#if workspaceActive && cherryPickInProgress}
      <section class="notice rebase" role="status">
        <Cherry size={17} aria-hidden="true" />
        <span>
          Cherry-pick in progress.
          {#if hasConflicts}
            Resolve conflicts, then continue.
          {:else}
            Continue when the index is ready, or abort to return to the previous state.
          {/if}
        </span>
        {#if hasConflicts}
          <button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
        {/if}
        <button type="button" onclick={continueCherryPick} disabled={isBusy || hasConflicts}>Continue</button>
        <button type="button" onclick={abortCherryPick} disabled={isBusy}>Abort</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-primary" type="button" onclick={openCloneDialog} disabled={isBusy}>
              <Download size={15} aria-hidden="true" />
              Clone
            </button>
            <button class="btn-secondary" type="button" onclick={initializeRepository} disabled={isBusy}>
              <Plus size={15} aria-hidden="true" /> Init
            </button>
            <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 repo-row-favorite"
                      class:active={isFavoriteRepo(repo.path)}
                      type="button"
                      onclick={(event) => toggleFavoriteRepo(repo.path, event)}
                      disabled={isBusy}
                      title={isFavoriteRepo(repo.path) ? "Remove from favorites" : "Add to favorites"}
                      aria-label={isFavoriteRepo(repo.path) ? `Remove ${repo.name} from favorites` : `Add ${repo.name} to favorites`}
                    >
                      <Star size={14} aria-hidden="true" />
                    </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">
                        {#if repo.branch}<strong><GitBranch size={11} aria-hidden="true" />{repo.branch}</strong>{:else}<em class="quiet">recent</em>{/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 repo-row-favorite"
                      class:active={isFavoriteRepo(repo.path)}
                      type="button"
                      onclick={(event) => toggleFavoriteRepo(repo.path, event)}
                      disabled={isBusy}
                      title={isFavoriteRepo(repo.path) ? "Remove from favorites" : "Add to favorites"}
                      aria-label={isFavoriteRepo(repo.path) ? `Remove ${repo.name} from favorites` : `Add ${repo.name} to favorites`}
                    >
                      <Star size={14} aria-hidden="true" />
                    </button>
                    <button class="repo-row-icon" type="button" onclick={(event) => removeRepoFromRecent(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>Favorites</h2>
              <span>{favoriteRepoRows.length}</span>
            </header>
            {#if favoriteRepoRows.length === 0}
              <div class="repo-empty">Mark repositories with the star to keep them here.</div>
            {:else}
              <div class="repo-table">
                {#each favoriteRepoRows 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 class="quiet">favorite</em>{/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 repo-row-favorite active"
                      type="button"
                      onclick={(event) => toggleFavoriteRepo(repo.path, event)}
                      disabled={isBusy}
                      title="Remove from favorites"
                      aria-label={`Remove ${repo.name} from favorites`}
                    >
                      <Star size={14} aria-hidden="true" />
                    </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="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-min-width: {HISTORY_ASIDE_MIN_WIDTH}px; --history-aside-width: {historyAsideWidth}px;"
      >

      <!-- Left sidebar: branches + explorer -->
      <aside
        class="left-sidebar"
        class:branch-collapsed={branchPanelCollapsed}
        class:stash-collapsed={stashPanelCollapsed}
        class:explorer-collapsed={explorerPanelCollapsed}
        class:all-collapsed={allLeftPanelsCollapsed}
        aria-label="Repository navigation"
        style="--branch-panel-height: {leftBranchPanelHeight}px; --stash-panel-height: {leftStashPanelHeight}px; grid-template-rows: {leftSidebarRows};"
      >
        <BranchPanel
          {branches}
          {localBranches}
          {remoteBranches}
          {tags}
          {hasRepository}
          {isBusy}
          onCheckout={checkout}
          onCompareBranch={compareBranchWithCurrent}
          onMerge={merge}
          onRebase={rebaseOnto}
          onCreateBranch={createNewBranch}
          onRenameBranch={openRenameBranchDialog}
          onDeleteBranch={deleteLocalBranch}
          onDeleteRemoteBranch={deleteTrackedRemoteBranch}
          onDeleteBranchFolder={deleteBranchFolder}
          onCreateTag={createNewTag}
          onDeleteTag={deleteLocalTag}
          onPushTag={pushLocalTag}
          onManageWorktrees={() => { void openWorktreeDialog(); }}
          onCreateWorktree={openBranchInWorktree}
          collapsed={branchPanelCollapsed}
          onToggleCollapsed={toggleBranchPanelCollapsed}
        />
        {#if !branchPanelCollapsed}
          <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
          <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
          <div
            class="panel-resize-handle left-panel-resize-handle branch-panel-resize-handle"
            class:resizing={resizingLeftBranchPanel}
            role="separator"
            aria-orientation="horizontal"
            aria-label="Resize branches panel height"
            aria-valuenow={leftBranchPanelHeight}
            aria-valuemin={LEFT_BRANCH_PANEL_MIN_HEIGHT}
            aria-valuemax={LEFT_BRANCH_PANEL_MAX_HEIGHT}
            tabindex="0"
            onpointerdown={startLeftBranchPanelResize}
            onpointermove={onLeftBranchPanelResizeMove}
            onpointerup={endLeftBranchPanelResize}
            onpointercancel={endLeftBranchPanelResize}
            onkeydown={onLeftBranchPanelResizeKeydown}
          ></div>
        {:else}
          <span class="left-panel-resize-placeholder" aria-hidden="true"></span>
        {/if}
        <StashPanel
          {stashes}
          changedCount={changedFiles.length}
          {hasRepository}
          {isBusy}
          onPush={saveStash}
          onApply={applyStashEntry}
          onPop={popStashEntry}
          onDrop={dropStashEntry}
          collapsed={stashPanelCollapsed}
          onToggleCollapsed={toggleStashPanelCollapsed}
        />
        {#if !explorerPanelCollapsed && (!stashPanelCollapsed || !branchPanelCollapsed)}
          <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
          <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
          <div
            class="panel-resize-handle left-panel-resize-handle stash-panel-resize-handle"
            class:resizing={resizingLeftStashPanel}
            role="separator"
            aria-orientation="horizontal"
            aria-label={stashPanelCollapsed ? "Resize files panel space" : "Resize stash and explorer panels"}
            aria-valuenow={stashPanelCollapsed ? leftBranchPanelHeight : leftStashPanelHeight}
            aria-valuemin={stashPanelCollapsed ? LEFT_BRANCH_PANEL_MIN_HEIGHT : LEFT_STASH_PANEL_MIN_HEIGHT}
            aria-valuemax={stashPanelCollapsed ? LEFT_BRANCH_PANEL_MAX_HEIGHT : LEFT_STASH_PANEL_MAX_HEIGHT}
            tabindex="0"
            onpointerdown={startLeftStashPanelResize}
            onpointermove={onLeftStashPanelResizeMove}
            onpointerup={endLeftStashPanelResize}
            onpointercancel={endLeftStashPanelResize}
            onkeydown={onLeftStashPanelResizeKeydown}
          ></div>
        {:else}
          <span class="left-panel-resize-placeholder" aria-hidden="true"></span>
        {/if}
        <ExplorerPanel
          {repoFiles}
          {expandedExplorerPaths}
          {selectedExplorerPath}
          {selectedExplorerKind}
          {hasRepository}
          {isBusy}
          language={appLanguage}
          editorName={editorToolName}
          diffName={diffToolName}
          onToggleFolder={toggleExplorerFolder}
          onExpandAllFolders={expandAllExplorerFolders}
          onCollapseAllFolders={collapseAllExplorerFolders}
          onSelectNode={selectExplorerNode}
          onOpenFile={openFileFromExplorer}
          onOpenInEditor={openExplorerFileInEditor}
          onExternalDiff={compareExplorerFileExternally}
          onFileHistory={openFileHistoryDialog}
          onBlame={openBlame}
          collapsed={explorerPanelCollapsed}
          onToggleCollapsed={toggleExplorerPanelCollapsed}
        />
      </aside>
      <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
      <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
      <div
        class="left-sidebar-resize-handle"
        class:resizing={resizingLeftSidebar}
        role="separator"
        aria-orientation="vertical"
        aria-label="Resize navigation sidebar width"
        aria-valuenow={leftSidebarWidth}
        aria-valuemin={LEFT_SIDEBAR_MIN_WIDTH}
        aria-valuemax={LEFT_SIDEBAR_MAX_WIDTH}
        tabindex="0"
        onpointerdown={startLeftSidebarResize}
        onpointermove={onLeftSidebarResizeMove}
        onpointerup={endLeftSidebarResize}
        onpointercancel={endLeftSidebarResize}
        onkeydown={onLeftSidebarResizeKeydown}
      ></div>

      <!-- 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 currentBranchIsLocalOnly}
              <span
                class="sync-local-only"
                title={appLanguage === "de"
                  ? "Dieser Branch existiert nur auf diesem Computer. Veröffentlichen richtet den Remote-Branch ein."
                  : "This branch exists only on this computer. Publish configures its remote branch."}
              >
                <CloudOff size={11} aria-hidden="true" />
                <strong>{appLanguage === "de" ? "Nur lokal" : "Local only"}</strong>
                <small>{appLanguage === "de" ? "noch nicht veröffentlicht" : "not published yet"}</small>
              </span>
            {:else}
              {#if status?.upstream}<span title="Upstream">{status.upstream}</span>{/if}
              <strong>{status?.ahead ?? 0} ahead</strong>
              <strong>{status?.behind ?? 0} behind</strong>
            {/if}
          </div>
        </div>

        <div class="top-section" style="--commit-panel-height: {commitPanelHeight}px;">
          <StatusPanel
            {changedFiles}
            {stagedCount}
            {unstagedCount}
            {hasRepository}
            {isBusy}
            {operation}
            {status}
            selectedFilePath={selectedExplorerPath}
            onSelectFile={selectFileFromStatus}
            onStage={stageFile}
            onUnstage={unstageFile}
            onDiscard={discardFiles}
            onDiscardMany={discardChanges}
            onPatch={openPreferredFileDiff}
            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}
            {commitAiReviewing}
            {commitAiSplitting}
            {canAmend}
            {amendMode}
            onCommit={commitChanges}
            onCommitMessageChange={updateCommitMessage}
            onGenerateCommitMessage={generateCommitMessageWithAi}
            onReviewStaged={reviewStagedWithAi}
            onSplitStaged={splitStagedWithAi}
            onOpenAiSettings={openAiSettings}
            onToggleAmend={toggleAmendMode}
            onUndoLastCommit={undoLastCommitChange}
          />
        </div>
      </section>

      <!-- 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 commit history 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>

      <!-- Right sidebar: commit graph -->
      <aside class="history-aside" aria-label="Commit history">
        <HistoryPanel
          {commits}
          {selectedCommitHash}
          {localBranchNames}
          {localBranchUpstreams}
          remoteBranchNames={remoteBranches.map((branch) => branch.name)}
          activeBranch={status?.current_branch ?? ""}
          activeUpstream={status?.upstream ?? ""}
          activeAhead={status?.ahead ?? 0}
          activeBehind={status?.behind ?? 0}
          repositoryKey={activeRepoPath}
          {hasRepository}
          {isBusy}
          hasMore={commitHistoryHasMore}
          isLoadingMore={commitHistoryLoadingMore}
          loadMoreError={commitHistoryLoadError}
          {expandedCommitHashes}
          onLoadMore={loadMoreCommitHistory}
          onRestoreCommit={restoreCommit}
          onPreviewCommitFile={previewCommitFileFromHistory}
          onCreateBranchFromCommit={openNewBranchDialog}
          onCherryPickCommit={cherryPickFromCommit}
          onRevertCommit={revertHistoryCommit}
          onOpenCommitNote={openCommitNoteDialog}
          onLoadCommitNote={loadCommitNotePreview}
          onSelectCommit={(commit) => { selectedCommitHash = commit.hash; }}
          onToggleCommitFiles={(hash) => {
            const next = new Set(expandedCommitHashes);
            if (next.has(hash)) next.delete(hash); else next.add(hash);
            expandedCommitHashes = next;
          }}
        />
      </aside>
    </section>
    {/if}
    <footer class="workspace-statusbar" aria-label="Application status summary">
      {#if workspaceActive}
        <span class:clean={status?.clean} class="workspace-health"><span aria-hidden="true"></span>{status?.clean ? "Working tree clean" : `${changedFiles.length} changed ${changedFiles.length === 1 ? "file" : "files"}`}</span>
      {/if}
      <span class="workspace-status-spacer"></span>
      {#if workspaceActive}
        <span class="workspace-branch"><GitBranch size={12} aria-hidden="true" />{status?.current_branch ?? "No branch"}</span>
        {#if currentBranchIsLocalOnly}
          <span class="workspace-local-only" title={appLanguage === "de" ? "Branch noch nicht veröffentlicht" : "Branch not published yet"}>
            <CloudOff size={11} aria-hidden="true" />{appLanguage === "de" ? "Nur lokal" : "Local only"}
          </span>
        {:else}
          <span class="ahead">↑ {status?.ahead ?? 0}</span>
          <span class="behind">↓ {status?.behind ?? 0}</span>
        {/if}
        <span class:active={autoRefreshEnabled} class="workspace-auto">Auto <i aria-hidden="true"></i></span>
      {/if}
      {#if appVersion}<span class="app-version" title={`Gitty version ${appVersion}`}>Gitty v{appVersion}</span>{/if}
    </footer>
  </div>
</main>

{#if commandPaletteOpen}
  <CommandPalette
    language={appLanguage}
    {hasRepository}
    {isBusy}
    {branches}
    files={repoFiles}
    {commits}
    onClose={() => { commandPaletteOpen = false; }}
    onCheckoutBranch={checkout}
    onOpenFile={openFileFromCommandPalette}
    onSelectCommit={selectCommitFromCommandPalette}
    onFetch={fetchRepo}
    onPull={pullRepo}
    onPush={pushRepo}
    onRefresh={refreshRepo}
    onOpenSearch={openGlobalSearchDialog}
    onOpenCompare={openCompareSelect}
    onOpenReflog={openReflog}
    onOpenInteractiveRebase={openInteractiveRebase}
    onOpenWorktrees={openWorktreeDialog}
    onOpenSyncSettings={openSyncOptions}
    onOpenSettings={openAppSettings}
    onOpenAiSettings={openAiSettings}
    onOpenHelp={openHelp}
  />
{/if}

{#if updateToastOpen}
  <UpdateToast
    state={updateToastState}
    version={updateVersion}
    currentVersion={updateCurrentVersion}
    progress={updateProgress}
    error={updateError}
    onInstall={installPendingUpdate}
    onLater={dismissUpdateToast}
    onDismiss={dismissUpdateToast}
  />
{/if}

{#if analyticsNoticeOpen}
  <AnalyticsNoticeDialog
    enabled={analyticsSettings.enabled}
    onContinue={acceptAnalyticsNotice}
  />
{/if}

{#if appSettingsOpen}
  <AppSettingsDialog
    analytics={analyticsSettings}
    theme={appTheme}
    language={appLanguage}
    autoRefresh={autoRefreshEnabled}
    externalTools={externalToolsSettings}
    detectedTools={detectedExternalTools}
    detectionPending={externalToolsDetectionPending}
    detectionUnavailable={externalToolsDetectionUnavailable}
    onRefreshDetectedTools={() => refreshDetectedExternalTools(false)}
    onSave={saveAppSettings}
    onClose={() => { appSettingsOpen = false; }}
  />
{/if}

{#if helpOpen}
  {#await import("./lib/components/HelpOverlay.svelte") then module}
    <module.default language={appLanguage} onClose={() => { helpOpen = false; }} />
  {/await}
{/if}

{#if aiReviewOpen && aiReviewResult}
  <AiReviewDialog
    result={aiReviewResult}
    provider={aiSettings.provider}
    isReviewing={commitAiReviewing}
    onRerun={reviewStagedWithAi}
    onClose={() => { aiReviewOpen = false; }}
  />
{/if}

{#if aiCommitSplitOpen && aiCommitPlan}
  <AiCommitSplitDialog
    plan={aiCommitPlan}
    isApplying={commitAiSplitting}
    onApply={applyAiCommitPlan}
    onClose={() => { if (!commitAiSplitting) aiCommitSplitOpen = false; }}
  />
{/if}

{#if linePatchOpen && linePatchFile}
  {#await import("./lib/components/LinePatchDialog.svelte") then module}
    <module.default
      file={linePatchFile}
      staged={linePatchStaged}
      patch={linePatchText}
      {isBusy}
      isLoading={linePatchLoading}
      error={linePatchError}
      onClose={closeLinePatch}
      onRefresh={refreshLinePatch}
      onApply={applyLinePatch}
      language={appLanguage}
      diffName={diffToolName}
      onExternalDiff={openCurrentLinePatchExternally}
    />
  {/await}
{/if}

{#if blameOpen}
  {#await import("./lib/components/BlameDialog.svelte") then module}
    <module.default
      filePath={blameFilePath}
      lines={blameLines}
      {isBusy}
      isLoading={blameLoading}
      error={blameError}
      onClose={closeBlame}
    />
  {/await}
{/if}

{#if pendingDiscard}
  <DiscardConfirmDialog
    files={pendingDiscard.kind === "patch" ? [pendingDiscard.file] : pendingDiscard.files}
    staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
    scope={pendingDiscard.kind === "patch" ? pendingDiscard.scope : "file"}
    {isBusy}
    onConfirm={confirmDiscard}
    onClose={closeDiscardConfirm}
  />
{/if}

{#if fileHistoryDialogOpen}
  {#await import("./lib/components/FileHistoryDialog.svelte") then module}
    <module.default
      {fileHistory}
      filePath={selectedExplorerPath}
      {isBusy}
      isLoading={fileHistoryLoading}
      error={fileHistoryError}
      onDiff={diffSelectedFileFromCommit}
      onRestore={restoreSelectedFileFromCommit}
      onClose={closeFileHistoryDialog}
    />
  {/await}
{/if}

{#if globalSearchOpen}
  {#await import("./lib/components/GlobalSearchDialog.svelte") then module}
    <module.default
      {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}
    />
  {/await}
{/if}

{#if worktreeDialogOpen}
  {#await import("./lib/components/WorktreeDialog.svelte") then module}
    <module.default
      {worktrees}
      {branches}
      initialBranch={worktreeInitialBranch}
      isLoading={worktreesLoading}
      {isBusy}
      error={worktreeError}
      onRefresh={refreshWorktrees}
      onOpen={openWorktreeTab}
      onAdd={createWorktree}
      onRemove={removeSelectedWorktree}
      onMove={moveSelectedWorktree}
      onLock={lockSelectedWorktree}
      onUnlock={unlockSelectedWorktree}
      onPrune={pruneStaleWorktrees}
      onRepair={repairSelectedWorktree}
      onClose={closeWorktreeDialog}
    />
  {/await}
{/if}

{#if gitLfsDialogOpen}
  {#await import("./lib/components/GitLfsDialog.svelte") then module}
    <module.default
      status={gitLfsStatus}
      language={appLanguage}
      isLoading={gitLfsLoading}
      {isBusy}
      error={gitLfsError}
      onRefresh={refreshGitLfsStatus}
      onInstall={activateGitLfs}
      onTrack={addGitLfsPattern}
      onUntrack={removeGitLfsPattern}
      onPull={pullGitLfsFiles}
      onPrune={pruneGitLfsCache}
      onClose={closeGitLfsDialog}
    />
  {/await}
{/if}

<!-- Create a branch from a specific commit in the history -->
{#if newBranchCommit}
  <NewBranchDialog
    commit={newBranchCommit}
    {isBusy}
    onCreate={createBranchFromCommit}
    onClose={() => { newBranchCommit = null; }}
  />
{/if}

{#if commitNoteTarget}
  <CommitNoteDialog
    commit={commitNoteTarget}
    note={commitNoteText}
    remotes={commitNoteRemotes}
    preferredRemote={commitNotePreferredRemote}
    language={appLanguage}
    isLoading={commitNoteLoading}
    isBusy={commitNoteBusy}
    error={commitNoteError}
    status={commitNoteStatus}
    onSave={saveActiveCommitNote}
    onDelete={deleteActiveCommitNote}
    onFetch={(remote) => syncActiveCommitNotes(remote, "fetch")}
    onPush={(remote) => syncActiveCommitNotes(remote, "push")}
    onClose={closeCommitNoteDialog}
  />
{/if}

<!-- Rename a local or remote branch from the branch context menu -->
{#if renameBranchTarget}
  <RenameBranchDialog
    branch={renameBranchTarget}
    {isBusy}
    onRename={submitRenameBranch}
    onClose={() => { renameBranchTarget = null; }}
  />
{/if}

<!-- Confirm deletion of a local or remote branch from the shared branch context menu -->
{#if deleteBranchTarget}
  <BranchDeleteConfirmDialog
    branch={deleteBranchTarget}
    force={deleteBranchForce}
    {isBusy}
    onConfirm={confirmDeleteBranch}
    onClose={closeDeleteBranchDialog}
  />
{/if}

<!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen}
  {#await import("./lib/components/AiSettingsDialog.svelte") then module}
    <module.default
      settings={aiSettings}
      localModels={localModelOptions}
      onSave={saveAiSettings}
      onClose={() => { aiSettingsOpen = false; }}
    />
  {/await}
{/if}

<!-- Interactive rebase -->
{#if interactiveRebaseOpen}
  {#await import("./lib/components/InteractiveRebaseDialog.svelte") then module}
    <module.default
      {branches}
      currentBranch={status?.current_branch ?? ""}
      base={interactiveRebaseBase}
      commits={interactiveRebaseCommits}
      isLoading={interactiveRebaseLoading}
      {isBusy}
      {operation}
      error={interactiveRebaseError}
      onBaseChange={loadInteractiveRebaseRange}
      onStart={runInteractiveRebase}
      onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }}
    />
  {/await}
{/if}

{#if reflogOpen}
  <ReflogDialog
    entries={reflogEntries}
    currentHash={reflogEntries.find((entry) => entry.selector === "HEAD@{0}")?.hash ?? ""}
    isLoading={reflogLoading}
    {isBusy}
    {operation}
    error={reflogError}
    onPreview={previewReflogEntry}
    onRestore={recoverReflogEntry}
    onClose={() => { if (!isBusy) reflogOpen = false; }}
  />
{/if}

<!-- Compare: pick two branches or commits to diff -->
{#if compareSelectOpen}
  <CompareSelectDialog
    {commits}
    {branches}
    {compareFrom}
    {compareTo}
    {canCompare}
    {isBusy}
    {operation}
    language={appLanguage}
    onCompareFromChange={(val) => { compareFrom = val; }}
    onCompareToChange={(val) => { compareTo = val; }}
    onCompare={compareSelectedTargets}
    onClose={() => { compareSelectOpen = false; }}
  />
{/if}

<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
{#if compareDialogOpen && comparison}
  {#await import("./lib/components/CompareDialog.svelte") then module}
    <module.default
      {comparison}
      {selectedDiffPath}
      {isBusy}
      language={appLanguage}
      fromLabel={comparisonFromLabel}
      toLabel={comparisonToLabel}
      highlightQuery={diffHighlightQuery}
      restoreLabel={pendingRestoreFile ? (appLanguage === "de" ? "Datei wiederherstellen" : "Restore file") : ""}
      onClose={closeCompareDialog}
      onRestore={restorePreviewedCommitFile}
      onSelectFile={selectDiffFile}
    />
  {/await}
{/if}

<!-- Credential dialog for authenticated remote actions -->
{#if credDialogOpen && credDialogAction}
  <CredentialDialog
    action={credDialogAction}
    error={credDialogError}
    {isBusy}
    initialUsername={credDialogUsername}
    initialMode={credDialogMode}
    onSubmit={handleCredentialSubmit}
    onCancel={() => {
      credDialogOpen = false;
      credDialogAction = null;
      pendingRemoteDelete = null;
      credDialogError = "";
      credDialogKey = null;
      credDialogUsername = "";
      credDialogMode = "credentials";
    }}
  />
{/if}

<!-- Clone repository dialog -->
{#if syncSettingsOpen}
  <SyncSettingsDialog
    remotes={syncSettingsRemotes}
    remoteBranches={remoteBranches.map((branch) => branch.name)}
    currentBranch={status?.current_branch ?? ""}
    currentUpstream={status?.upstream ?? ""}
    strategy={pullStrategy}
    {selectedRemote}
    {isBusy}
    language={appLanguage}
    onSaveSync={saveSyncSettings}
    onAddRemote={addSyncRemote}
    onUpdateRemote={updateSyncRemote}
    onRemoveRemote={removeSyncRemote}
    onClose={() => { if (!isBusy) syncSettingsOpen = false; }}
  />
{/if}

<!-- Clone repository dialog -->
{#if cloneDialogOpen}
  <CloneRepositoryDialog
    isBusy={operation === "Cloning repository"}
    error={cloneDialogError}
    onClone={cloneRepo}
    onClose={() => { if (!isBusy) cloneDialogOpen = false; }}
  />
{/if}

<!-- Full-screen overlay while a repository is being opened -->
{#if openingRepo}
  <RepoLoadingOverlay repoName={repoDisplayName} />
{/if}

<!-- Full-screen overlay while a repository is being cloned -->
{#if cloningRepo}
  <RepoLoadingOverlay label="Cloning repository" repoName={cloneDisplayName} />
{/if}

<!-- Conflict resolve dialog -->
{#if resolveDialogOpen}
  {#await import("./lib/components/ResolveDialog.svelte") then module}
    <module.default
      {conflictedFiles}
      {conflictTarget}
      {conflict}
      {preparedResolutions}
      {isBusy}
      {operation}
      language={appLanguage}
      mergeName={mergeToolName}
      onClose={() => { resolveDialogOpen = false; }}
      onSelectFile={selectConflictFile}
      onMarkResolved={handleMarkResolved}
      onApply={applyPreparedResolutions}
      onExternalMerge={openConflictInExternalMerge}
    />
  {/await}
{/if}
