The handling of the Escape key has been refined to ensure that it closes the correct dialog based on the current state. Additionally, the z-index for the compare dialog backdrop has been updated to ensure proper layering with other dialogs. - Enhanced Escape key functionality for better user experience - Updated z-index for compare dialog backdrop to avoid overlap
4937 lines
181 KiB
Svelte
4937 lines
181 KiB
Svelte
<script lang="ts">
|
||
import { onDestroy, onMount, tick } from "svelte";
|
||
import { getVersion } from "@tauri-apps/api/app";
|
||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||
import { AlertCircle, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, Star, X } from "@lucide/svelte";
|
||
|
||
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 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,
|
||
deleteTag,
|
||
deleteRemoteBranch,
|
||
initRepository,
|
||
diffFileAgainstWorkingTree,
|
||
compareFileToParent,
|
||
fetchRemote,
|
||
getFileBlame,
|
||
getStatus,
|
||
lastCommitMessage,
|
||
listBranches,
|
||
listRemotes,
|
||
listStashes,
|
||
listTags,
|
||
listWorktrees,
|
||
listCommits,
|
||
listFileHistory,
|
||
listInteractiveRebaseCommits,
|
||
listReflog,
|
||
listRepositoryFiles,
|
||
mergeBranch,
|
||
mergeAbort,
|
||
mergeContinue,
|
||
openRepoInExplorer,
|
||
openRepositoryFile,
|
||
openRepositoryBundle,
|
||
lockWorktree,
|
||
moveWorktree,
|
||
pruneWorktrees,
|
||
pull,
|
||
push,
|
||
pushTag,
|
||
removeRemote,
|
||
removeWorktree,
|
||
repairWorktree,
|
||
revertCommit,
|
||
setBranchUpstream,
|
||
updateRemote,
|
||
renameBranch,
|
||
rebaseAbort,
|
||
rebaseBranch,
|
||
rebaseContinue,
|
||
getRemoteUrl,
|
||
credLoad,
|
||
credSave,
|
||
credDelete,
|
||
getFilePatch,
|
||
readConflict,
|
||
resolveConflict,
|
||
resolveConflictSide,
|
||
restoreFileFromCommit,
|
||
restoreReflogEntry,
|
||
restoreFiles,
|
||
restoreToCommit,
|
||
searchCodeIntroductions,
|
||
startInteractiveRebase,
|
||
setSyncBadge,
|
||
stageFiles,
|
||
stashApply,
|
||
stashDrop,
|
||
stashPop,
|
||
stashPush,
|
||
undoLastCommit,
|
||
unlockWorktree,
|
||
unstageFiles,
|
||
} from "./lib/git";
|
||
|
||
import type {
|
||
AiReviewResult,
|
||
AiCommitPlan,
|
||
AiSettings,
|
||
AppLanguage,
|
||
AppTheme,
|
||
AnalyticsSettings,
|
||
CommitAiPhase,
|
||
ConflictFile,
|
||
ExplorerNode,
|
||
ExplorerNodeKind,
|
||
GitBlameLine,
|
||
GitBranch as GitBranchInfo,
|
||
GitCommit,
|
||
GitCommitFile,
|
||
GitCommitComparison,
|
||
GitDiffFile,
|
||
GitFileStatus,
|
||
GitRepositoryFile,
|
||
GitRemote,
|
||
PullStrategy,
|
||
GitSearchHit,
|
||
GitStash,
|
||
GitStatus,
|
||
GitTag,
|
||
GitWorktree,
|
||
LocalModelOption,
|
||
PatchApplyAction,
|
||
PreparedResolution,
|
||
RebaseCommit,
|
||
RebasePlanItem,
|
||
ReflogEntry,
|
||
StoredCredential,
|
||
} from "./lib/types";
|
||
|
||
import {
|
||
orgKeyFromUrl,
|
||
isCredentialExpired,
|
||
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";
|
||
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;
|
||
}
|
||
|
||
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 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 = 560;
|
||
const HISTORY_ASIDE_MAX_WIDTH = 920;
|
||
const ERROR_AUTO_HIDE_MS = 6000;
|
||
const COMMIT_HISTORY_PAGE_SIZE = 50;
|
||
|
||
// ── State ──────────────────────────────────────────────────────────────────
|
||
|
||
let repoPath = "";
|
||
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 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 analyticsNoticeOpen = false;
|
||
let analyticsSettings: AnalyticsSettings = defaultAnalyticsSettings();
|
||
let appTheme: AppTheme = loadThemePreference();
|
||
let appLanguage: AppLanguage = loadLanguagePreference();
|
||
let localModelOptions: LocalModelOption[] = [];
|
||
let errorMessage = "";
|
||
let operation = "";
|
||
let compareFrom = "";
|
||
let compareTo = "";
|
||
let comparison: GitCommitComparison | null = null;
|
||
let newBranchCommit: GitCommit | null = null;
|
||
let renameBranchTarget: GitBranchInfo | null = null;
|
||
let deleteBranchTarget: GitBranchInfo | null = null;
|
||
let deleteBranchForce = false;
|
||
let worktreeDialogOpen = false;
|
||
let worktreeInitialBranch = "";
|
||
let worktrees: GitWorktree[] = [];
|
||
let worktreesLoading = false;
|
||
let worktreeError = "";
|
||
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 credDialogError = "";
|
||
let credDialogKey: string | null = null;
|
||
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;
|
||
const STARTUP_FETCH_MAX_WAIT_MS = 20_000;
|
||
let backgroundFetchTimer: ReturnType<typeof setInterval> | undefined;
|
||
let backgroundRepoStatusTimer: ReturnType<typeof setInterval> | undefined;
|
||
let backgroundFetchInFlight = false;
|
||
let backgroundRepoStatusInFlight = false;
|
||
let backgroundRepoStatusIndex = 0;
|
||
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);
|
||
$: remoteBranches = branches.filter((b) => b.remote);
|
||
$: 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;
|
||
|
||
$: applyThemePreference(appTheme);
|
||
$: applyLanguagePreference(appLanguage);
|
||
|
||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||
|
||
onMount(() => {
|
||
themeMediaQuery = window.matchMedia("(prefers-color-scheme: light)");
|
||
themeMediaQuery.addEventListener("change", handleSystemThemeChange);
|
||
void runStartupSequence();
|
||
void getVersion().then((version) => { appVersion = version; }).catch(() => { appVersion = ""; });
|
||
});
|
||
|
||
onDestroy(() => {
|
||
themeMediaQuery?.removeEventListener("change", handleSystemThemeChange);
|
||
if (autoRefreshTimer) clearInterval(autoRefreshTimer);
|
||
if (backgroundRepoStatusTimer) clearInterval(backgroundRepoStatusTimer);
|
||
if (backgroundFetchTimer) clearInterval(backgroundFetchTimer);
|
||
if (commitAiPollTimer) clearInterval(commitAiPollTimer);
|
||
if (cloneDialogErrorTimer) clearTimeout(cloneDialogErrorTimer);
|
||
Object.values(errorAutoHideStates).forEach((state) => {
|
||
if (state?.timer) clearTimeout(state.timer);
|
||
});
|
||
if (activeFileHistoryRequestId) void cancelFileHistory(activeFileHistoryRequestId);
|
||
});
|
||
|
||
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() {
|
||
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();
|
||
await Promise.race([
|
||
fetchOpenRepositoriesDuringStartup(),
|
||
wait(STARTUP_FETCH_MAX_WAIT_MS),
|
||
]);
|
||
} finally {
|
||
const remainingSplashTime = STARTUP_SPLASH_MIN_VISIBLE_MS - (performance.now() - startupStartedAt);
|
||
if (remainingSplashTime > 0) await wait(remainingSplashTime);
|
||
|
||
await closeStartupSplashscreen();
|
||
startBackgroundTimers();
|
||
void backgroundRepoStatusTick(false);
|
||
}
|
||
}
|
||
|
||
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.
|
||
}
|
||
}
|
||
}
|
||
} 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.
|
||
async function backgroundFetchTick() {
|
||
if (!autoRefreshEnabled) return;
|
||
|
||
if (activeView === "repository" && activeRepoPath && !backgroundFetchInFlight
|
||
&& !isBusy && Date.now() - lastRepoSwitchAt >= BACKGROUND_FETCH_AFTER_SWITCH_GRACE_MS) {
|
||
backgroundFetchInFlight = true;
|
||
try {
|
||
await fetchRemote(activeRepoPath);
|
||
applyStatus(await getStatus(activeRepoPath));
|
||
await refreshRefsAndCommitGraph(activeRepoPath);
|
||
} catch {
|
||
// ignore — see comment above
|
||
} finally {
|
||
backgroundFetchInFlight = false;
|
||
}
|
||
}
|
||
|
||
void backgroundRepoStatusTick(true);
|
||
}
|
||
|
||
async function backgroundFetchRepo(path: string) {
|
||
if (!autoRefreshEnabled || !path || backgroundFetchInFlight) return;
|
||
|
||
backgroundFetchInFlight = true;
|
||
try {
|
||
await fetchRemote(path);
|
||
const nextStatus = await getStatus(path);
|
||
if (sameRepoPath(path, activeRepoPath)) {
|
||
applyStatus(nextStatus);
|
||
await refreshRefsAndCommitGraph(path);
|
||
} else updateRepoManagementStatus(path, nextStatus);
|
||
} catch {
|
||
// ignore; manual Fetch/Pull surfaces auth or network problems
|
||
} 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 (!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 (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 (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || 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;
|
||
applyStatus(nextStatus);
|
||
// 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;
|
||
const previousHeadHash = lastFileHistoryHeadHash;
|
||
await refreshBranchList(path, bundle.branches);
|
||
await refreshTags(path, bundle.tags);
|
||
await refreshStashes(path, bundle.stashes);
|
||
await refreshCommitHistory(path, bundle.commits);
|
||
await refreshExplorerFiles(path, bundle.files);
|
||
// File history reflects `git log`, which only changes when HEAD actually moves
|
||
// (new commit, checkout, merge, ...) — skip the reload otherwise so a plain
|
||
// working-tree/status change (staging, edits) doesn't keep re-fetching and
|
||
// flickering the currently viewed file's history.
|
||
if (lastFileHistoryHeadHash !== previousHeadHash && sameRepoPath(path, activeRepoPath)) {
|
||
await refreshFileHistory(path);
|
||
}
|
||
} 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) {
|
||
const autoRefreshWasEnabled = autoRefreshEnabled;
|
||
analyticsSettings = next;
|
||
appTheme = nextTheme;
|
||
appLanguage = nextLanguage;
|
||
autoRefreshEnabled = nextAutoRefresh;
|
||
persistAnalyticsSettings(next);
|
||
persistThemePreference(nextTheme);
|
||
persistLanguagePreference(nextLanguage);
|
||
persistStoredBoolean(AUTO_REFRESH_ENABLED_KEY, nextAutoRefresh);
|
||
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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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();
|
||
fileHistory = [];
|
||
fileHistoryLoading = false;
|
||
fileHistoryError = "";
|
||
fileHistoryDialogOpen = false;
|
||
compareFrom = "";
|
||
compareTo = "";
|
||
comparison = null;
|
||
compareSelectOpen = false;
|
||
compareDialogOpen = false;
|
||
interactiveRebaseOpen = false;
|
||
interactiveRebaseBase = "";
|
||
interactiveRebaseCommits = [];
|
||
interactiveRebaseError = "";
|
||
reflogOpen = false;
|
||
reflogEntries = [];
|
||
reflogError = "";
|
||
selectedDiffPath = "";
|
||
pendingRestoreFile = null;
|
||
newBranchCommit = null;
|
||
globalSearchResults = [];
|
||
deleteBranchTarget = null;
|
||
deleteBranchForce = false;
|
||
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[]) {
|
||
branches = prefetched ?? (await listBranches(path));
|
||
}
|
||
|
||
async function refreshTags(path = activeRepoPath, prefetched?: GitTag[]) {
|
||
tags = prefetched ?? (await listTags(path));
|
||
}
|
||
|
||
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
|
||
stashes = prefetched ?? (await listStashes(path));
|
||
}
|
||
|
||
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 hashes = new Set(commits.map((c) => c.hash));
|
||
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
||
if (compareTo && !hashes.has(compareTo)) compareTo = "";
|
||
if (comparison && comparison.to_hash.length > 0 && (!hashes.has(comparison.from_hash) || !hashes.has(comparison.to_hash))) {
|
||
comparison = null;
|
||
compareDialogOpen = false;
|
||
selectedDiffPath = "";
|
||
pendingRestoreFile = null;
|
||
}
|
||
}
|
||
|
||
async function 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[]) {
|
||
repoFiles = prefetched ?? (await listRepositoryFiles(path));
|
||
const folderPaths = allExplorerFolderPaths(repoFiles);
|
||
expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder)));
|
||
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
||
selectedExplorerPath = "";
|
||
selectedExplorerKind = "file";
|
||
hideAndResetFileHistory();
|
||
}
|
||
}
|
||
|
||
async function refreshRefsAndCommitGraph(path = activeRepoPath) {
|
||
const previousHeadHash = lastFileHistoryHeadHash;
|
||
await refreshBranchList(path);
|
||
await refreshTags(path);
|
||
await 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);
|
||
applyStatus(bundle.status);
|
||
if (globalSearchBusy) void cancelGlobalSearch();
|
||
activeView = "repository";
|
||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||
await refreshTags(activeRepoPath, bundle.tags);
|
||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||
lastRepoSwitchAt = Date.now();
|
||
trackEvent("repository_opened", {
|
||
changed_files: bundle.status.files.length,
|
||
has_upstream: bundle.status.upstream ? 1 : 0,
|
||
});
|
||
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,
|
||
) {
|
||
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 && !isCredentialExpired(stored)) {
|
||
await cloneRepo(remoteUrl, parentPath, directoryName, stored.username, stored.password, credentialKey, true);
|
||
return;
|
||
}
|
||
if (stored && credentialKey) await credDelete(credentialKey).catch(() => {});
|
||
}
|
||
|
||
operation = "Cloning repository";
|
||
errorMessage = "";
|
||
setCloneDialogError("");
|
||
try {
|
||
const bundle = await cloneRepository(
|
||
remoteUrl,
|
||
parentPath,
|
||
directoryName || undefined,
|
||
username,
|
||
password,
|
||
COMMIT_HISTORY_PAGE_SIZE + 1,
|
||
);
|
||
resetRepositoryState(false);
|
||
applyStatus(bundle.status);
|
||
if (globalSearchBusy) void cancelGlobalSearch();
|
||
activeView = "repository";
|
||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||
await refreshTags(activeRepoPath, bundle.tags);
|
||
await refreshStashes(activeRepoPath, bundle.stashes);
|
||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||
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) void credDelete(credentialKey).catch(() => {});
|
||
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 () => {
|
||
applyStatus(await getStatus(activeRepoPath));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshStashes(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
trackEvent("branch_created");
|
||
});
|
||
}
|
||
|
||
function renameLocalBranch(branch: GitBranchInfo) {
|
||
if (!activeRepoPath || branch.remote) return;
|
||
renameBranchTarget = branch;
|
||
trackEvent("branch_rename_dialog_opened");
|
||
}
|
||
|
||
async function submitRenameBranch(branchName: string) {
|
||
const branch = renameBranchTarget;
|
||
const name = branchName.trim();
|
||
if (!activeRepoPath || !branch || branch.remote || !name || name === branch.name) return;
|
||
|
||
await runOperation(`Renaming ${branch.name}`, async () => {
|
||
applyStatus(await renameBranch(activeRepoPath, branch.name, name));
|
||
renameBranchTarget = null;
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
trackEvent("branch_renamed");
|
||
});
|
||
}
|
||
|
||
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 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);
|
||
operation = `Deleting ${branch.name} from remote`;
|
||
errorMessage = "";
|
||
try {
|
||
applyStatus(await deleteRemoteBranch(activeRepoPath, remote, remoteBranch));
|
||
deleteBranchTarget = null;
|
||
await refreshRefsAndCommitGraph(activeRepoPath);
|
||
trackEvent("remote_branch_deleted");
|
||
} catch (error) {
|
||
errorMessage = errorToMessage(error);
|
||
} finally {
|
||
operation = "";
|
||
}
|
||
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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 = "";
|
||
}
|
||
|
||
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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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;
|
||
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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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();
|
||
const stored = await loadStoredCredential(key);
|
||
const credential = stored && !isCredentialExpired(stored) ? 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");
|
||
});
|
||
}
|
||
|
||
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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
trackEvent("commit_cherry_picked");
|
||
});
|
||
}
|
||
|
||
async function continueCherryPick() {
|
||
if (!activeRepoPath || !cherryPickInProgress || hasConflicts) return;
|
||
await runOperation("Continuing cherry-pick", async () => {
|
||
applyStatus(await cherryPickContinue(activeRepoPath));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
trackEvent("cherry_pick_aborted");
|
||
});
|
||
}
|
||
|
||
// Resolve the keychain key (host/org) for the active repo's remote.
|
||
async function currentCredKey(): Promise<string | null> {
|
||
if (!activeRepoPath) return null;
|
||
try {
|
||
const url = await getRemoteUrl(activeRepoPath);
|
||
return url ? orgKeyFromUrl(url) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function loadStoredCredential(key: string | null): Promise<StoredCredential | null> {
|
||
if (!key) return null;
|
||
try {
|
||
return await credLoad(key);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function openCredentialDialog(action: CredentialAction, key?: string | null) {
|
||
if (!activeRepoPath && action !== "clone") return;
|
||
credDialogError = "";
|
||
credDialogAction = action;
|
||
credDialogKey = key === undefined && action !== "clone" ? await currentCredKey() : (key ?? null);
|
||
credDialogOpen = true;
|
||
trackEvent("credential_dialog_opened", {
|
||
action,
|
||
});
|
||
}
|
||
|
||
// Post-process a pull/push result: surface errors, and on rejected/expired
|
||
// credentials drop the stored entry and re-open the login dialog.
|
||
function handleRemoteResult(action: "push" | "pull" | "fetch", key: string | null, fromStore: boolean) {
|
||
if (!errorMessage) {
|
||
credDialogOpen = false;
|
||
credDialogAction = null;
|
||
return;
|
||
}
|
||
const auth = isAuthError(errorMessage);
|
||
const message = stripAuthPrefix(errorMessage);
|
||
errorMessage = "";
|
||
|
||
if (fromStore) {
|
||
if (auth) {
|
||
if (key) void credDelete(key).catch(() => {});
|
||
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 {
|
||
credDialogError = message || "Sign-in failed.";
|
||
}
|
||
}
|
||
|
||
async function doActualPull(
|
||
username: string,
|
||
password: string,
|
||
key: string | null,
|
||
fromStore: boolean,
|
||
) {
|
||
errorMessage = "";
|
||
await runOperation("Pulling", async () => {
|
||
applyStatus(await pull(activeRepoPath, username, password, pullStrategy, selectedRemote || undefined));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
trackEvent("repository_pulled", {
|
||
from_stored_credential: fromStore ? 1 : 0,
|
||
changed_files: status?.files.length ?? 0,
|
||
});
|
||
});
|
||
handleRemoteResult("pull", key, fromStore);
|
||
}
|
||
|
||
async function doActualFetch(
|
||
username: string,
|
||
password: string,
|
||
key: string | null,
|
||
fromStore: boolean,
|
||
) {
|
||
errorMessage = "";
|
||
await runOperation("Fetching", async () => {
|
||
applyStatus(await fetchRemote(activeRepoPath, username, password, 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);
|
||
}
|
||
|
||
async function doActualPush(
|
||
username: string,
|
||
password: string,
|
||
key: string | null,
|
||
fromStore: boolean,
|
||
) {
|
||
errorMessage = "";
|
||
await runOperation("Pushing", async () => {
|
||
applyStatus(await push(activeRepoPath, username, password, remoteActionForceWithLease, selectedRemote || undefined));
|
||
remoteActionForceWithLease = false;
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
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));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
});
|
||
|
||
if (errorMessage) {
|
||
handleRemoteResult("pull", key, fromStore);
|
||
return;
|
||
}
|
||
|
||
if (statusHasConflicts(status)) {
|
||
credDialogOpen = false;
|
||
credDialogAction = null;
|
||
errorMessage = "Pull produced merge conflicts. Resolve the conflicts, commit the merge, and then push again.";
|
||
return;
|
||
}
|
||
|
||
await runOperation("Pushing after pull", async () => {
|
||
applyStatus(await push(activeRepoPath, username, password));
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
trackEvent("repository_pushed_after_pull", {
|
||
from_stored_credential: fromStore ? 1 : 0,
|
||
changed_files: status?.files.length ?? 0,
|
||
});
|
||
});
|
||
}
|
||
|
||
handleRemoteResult("push", key, fromStore);
|
||
}
|
||
|
||
async function handleCredentialSubmit(
|
||
username: string,
|
||
password: string,
|
||
save: boolean,
|
||
expiresAt: string | null,
|
||
) {
|
||
const key = credDialogKey;
|
||
if (credDialogAction === "pull") await doActualPull(username, password, key, false);
|
||
else if (credDialogAction === "push") await doActualPush(username, password, key, false);
|
||
else if (credDialogAction === "fetch") await doActualFetch(username, password, key, false);
|
||
else if (credDialogAction === "clone" && pendingClone) {
|
||
await cloneRepo(
|
||
pendingClone.remoteUrl,
|
||
pendingClone.parentPath,
|
||
pendingClone.directoryName,
|
||
username,
|
||
password,
|
||
key,
|
||
false,
|
||
);
|
||
}
|
||
|
||
// Only persist once the operation actually succeeded (dialog has closed).
|
||
if (!credDialogOpen && save && key) {
|
||
try {
|
||
await credSave(key, username, password, expiresAt);
|
||
} catch (error) {
|
||
errorMessage = errorToMessage(error);
|
||
}
|
||
}
|
||
}
|
||
|
||
async function startRemoteAction(action: "push" | "pull" | "fetch") {
|
||
if (!activeRepoPath) return;
|
||
trackEvent("remote_action_started", {
|
||
action,
|
||
});
|
||
const key = await currentCredKey();
|
||
const stored = await loadStoredCredential(key);
|
||
|
||
if (stored && !isCredentialExpired(stored)) {
|
||
if (action === "pull") await doActualPull(stored.username, stored.password, key, true);
|
||
else if (action === "fetch") await doActualFetch(stored.username, stored.password, key, true);
|
||
else await doActualPush(stored.username, stored.password, key, true);
|
||
return;
|
||
}
|
||
|
||
// Expired entry → clean it up before prompting again.
|
||
if (stored && key) await credDelete(key).catch(() => {});
|
||
await openCredentialDialog(action, key);
|
||
}
|
||
|
||
async function fetchRepo() {
|
||
await startRemoteAction("fetch");
|
||
}
|
||
|
||
async function pullRepo() {
|
||
await startRemoteAction("pull");
|
||
}
|
||
|
||
async function pushRepo() {
|
||
await startRemoteAction("push");
|
||
}
|
||
|
||
async function 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 refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(activeRepoPath); await refreshFileHistory(activeRepoPath);
|
||
});
|
||
}
|
||
|
||
async function continueMerge() {
|
||
if (!activeRepoPath) return;
|
||
await runOperation("Continuing merge", async () => { applyStatus(await mergeContinue(activeRepoPath)); await refreshCommitHistory(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 refreshCommitHistory(activeRepoPath); await refreshExplorerFiles(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 refreshStashes(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
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 refreshStashes(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
trackEvent("stash_applied");
|
||
});
|
||
}
|
||
|
||
async function popStashEntry(stash: GitStash) {
|
||
if (!activeRepoPath) return;
|
||
await runOperation(`Popping ${stash.selector}`, async () => {
|
||
applyStatus(await stashPop(activeRepoPath, stash.selector));
|
||
await refreshStashes(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
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 refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
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 refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
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 refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
|
||
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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
trackEvent("commit_created", { amend: 1 });
|
||
});
|
||
return;
|
||
}
|
||
|
||
const trackedStagedCount = stagedCount;
|
||
await runOperation("Committing", async () => {
|
||
applyStatus(await commit(activeRepoPath, message));
|
||
commitMessage = "";
|
||
lastLocalAiGeneratedMessage = "";
|
||
await refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 refreshBranchList(activeRepoPath);
|
||
await refreshCommitHistory(activeRepoPath);
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(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 refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
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;
|
||
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 restoreSelectedFileFromCommit(target: GitCommit) {
|
||
if (!activeRepoPath || !selectedExplorerPath) return;
|
||
const kind = selectedExplorerKind === "folder" ? "folder" : "file";
|
||
const confirmed = window.confirm(`Restore ${kind} ${selectedExplorerPath} from ${target.short_hash}?\n\nThis changes the selected ${kind} in your working tree so you can review and commit it.`);
|
||
if (!confirmed) return;
|
||
await runOperation(`Restoring ${selectedExplorerPath}`, async () => {
|
||
applyStatus(await restoreFileFromCommit(activeRepoPath, target.hash, selectedExplorerPath));
|
||
await refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
trackEvent("selected_file_restored_from_commit", {
|
||
kind,
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Compare ────────────────────────────────────────────────────────────────
|
||
|
||
function openCompareSelect() {
|
||
if (!hasRepository) return;
|
||
compareSelectOpen = true;
|
||
trackEvent("compare_opened");
|
||
}
|
||
|
||
function openGlobalSearchDialog() {
|
||
globalSearchOpen = true;
|
||
trackEvent("global_search_opened");
|
||
}
|
||
|
||
function openHelp() {
|
||
helpOpen = true;
|
||
trackEvent("help_opened");
|
||
}
|
||
|
||
async function compareSelectedCommits() {
|
||
if (!canCompare) return;
|
||
await runOperation("Comparing commits", async () => {
|
||
const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
|
||
comparison = result;
|
||
selectedDiffPath = result.files[0]?.path ?? "";
|
||
diffHighlightQuery = "";
|
||
pendingRestoreFile = null;
|
||
compareSelectOpen = false;
|
||
compareDialogOpen = true;
|
||
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;
|
||
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;
|
||
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;
|
||
await runOperation("Loading conflicts", async () => {
|
||
preparedResolutions = {};
|
||
resolveDialogOpen = true;
|
||
await loadConflict(first);
|
||
trackEvent("resolve_dialog_opened", {
|
||
conflicts: conflictedFiles.length,
|
||
});
|
||
});
|
||
}
|
||
|
||
async function selectConflictFile(path: string) {
|
||
if (path === conflictTarget || isBusy) return;
|
||
await runOperation(`Loading ${path}`, async () => {
|
||
await loadConflict(path);
|
||
trackEvent("conflict_file_selected");
|
||
});
|
||
}
|
||
|
||
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 refreshExplorerFiles(activeRepoPath);
|
||
await refreshFileHistory(activeRepoPath);
|
||
|
||
const remaining = (nextStatus?.files ?? status?.files ?? []).filter(
|
||
(f) => f.staged === "conflicted" || f.unstaged === "conflicted",
|
||
);
|
||
if (remaining.length === 0) {
|
||
resolveDialogOpen = false;
|
||
conflict = null;
|
||
conflictTarget = "";
|
||
} else {
|
||
await loadConflict(remaining[0].path);
|
||
}
|
||
trackEvent("conflicts_resolved", {
|
||
files: entries.length,
|
||
remaining: remaining.length,
|
||
});
|
||
});
|
||
}
|
||
|
||
// ── Event handlers ─────────────────────────────────────────────────────────
|
||
|
||
function handleWindowKeydown(event: KeyboardEvent) {
|
||
if ((event.ctrlKey || event.metaKey) && event.key === "/") {
|
||
event.preventDefault();
|
||
openHelp();
|
||
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" && 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={() => { appSettingsOpen = true; }}
|
||
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}
|
||
language={appLanguage}
|
||
onFetch={fetchRepo}
|
||
onPull={pullRepo}
|
||
onPush={pushRepo}
|
||
onRefresh={refreshRepo}
|
||
onSearch={openGlobalSearchDialog}
|
||
onCompare={openCompareSelect}
|
||
onInteractiveRebase={openInteractiveRebase}
|
||
onReflog={openReflog}
|
||
onOpenInExplorer={openActiveRepoInExplorer}
|
||
onFetchPrune={fetchPruneRepo}
|
||
onForcePush={forcePushRepo}
|
||
onSyncOptions={openSyncOptions}
|
||
/>
|
||
{/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-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}
|
||
onMerge={merge}
|
||
onRebase={rebaseOnto}
|
||
onCreateBranch={createNewBranch}
|
||
onRenameBranch={renameLocalBranch}
|
||
onDeleteBranch={deleteLocalBranch}
|
||
onDeleteRemoteBranch={deleteTrackedRemoteBranch}
|
||
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}
|
||
onToggleFolder={toggleExplorerFolder}
|
||
onExpandAllFolders={expandAllExplorerFolders}
|
||
onCollapseAllFolders={collapseAllExplorerFolders}
|
||
onSelectNode={selectExplorerNode}
|
||
onOpenFile={openFileFromExplorer}
|
||
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 status?.upstream}<span title="Upstream">{status.upstream}</span>{/if}
|
||
<strong>{status?.ahead ?? 0} ahead</strong>
|
||
<strong>{status?.behind ?? 0} behind</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="top-section" style="--commit-panel-height: {commitPanelHeight}px;">
|
||
<StatusPanel
|
||
{changedFiles}
|
||
{stagedCount}
|
||
{unstagedCount}
|
||
{hasRepository}
|
||
{isBusy}
|
||
{operation}
|
||
{status}
|
||
selectedFilePath={selectedExplorerPath}
|
||
onSelectFile={selectFileFromStatus}
|
||
onStage={stageFile}
|
||
onUnstage={unstageFile}
|
||
onDiscard={discardFiles}
|
||
onDiscardMany={discardChanges}
|
||
onPatch={openLinePatch}
|
||
onStageAll={stageAllFiles}
|
||
onUnstageAll={unstageAllFiles}
|
||
/>
|
||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||
<div
|
||
class="panel-resize-handle"
|
||
class:resizing={resizingCommitPanel}
|
||
role="separator"
|
||
aria-orientation="horizontal"
|
||
aria-label="Resize commit panel height"
|
||
aria-valuenow={commitPanelHeight}
|
||
aria-valuemin={COMMIT_PANEL_MIN_HEIGHT}
|
||
aria-valuemax={COMMIT_PANEL_MAX_HEIGHT}
|
||
tabindex="0"
|
||
onpointerdown={startCommitPanelResize}
|
||
onpointermove={onCommitPanelResizeMove}
|
||
onpointerup={endCommitPanelResize}
|
||
onpointercancel={endCommitPanelResize}
|
||
onkeydown={onCommitPanelResizeKeydown}
|
||
></div>
|
||
<CommitPanel
|
||
{commitMessage}
|
||
{canCommit}
|
||
{commitBlockReason}
|
||
{hasRepository}
|
||
{isBusy}
|
||
{operation}
|
||
{stagedCount}
|
||
commitAiProvider={aiSettings.provider}
|
||
{commitAiPhase}
|
||
{commitAiGenerating}
|
||
{commitAiReviewing}
|
||
{commitAiSplitting}
|
||
{canAmend}
|
||
{amendMode}
|
||
onCommit={commitChanges}
|
||
onCommitMessageChange={updateCommitMessage}
|
||
onGenerateCommitMessage={generateCommitMessageWithAi}
|
||
onReviewStaged={reviewStagedWithAi}
|
||
onSplitStaged={splitStagedWithAi}
|
||
onOpenAiSettings={() => { aiSettingsOpen = true; }}
|
||
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}
|
||
{localBranchNames}
|
||
activeBranch={status?.current_branch ?? ""}
|
||
activeUpstream={status?.upstream ?? ""}
|
||
repositoryKey={activeRepoPath}
|
||
{hasRepository}
|
||
{isBusy}
|
||
hasMore={commitHistoryHasMore}
|
||
isLoadingMore={commitHistoryLoadingMore}
|
||
loadMoreError={commitHistoryLoadError}
|
||
{expandedCommitHashes}
|
||
onLoadMore={loadMoreCommitHistory}
|
||
onRestoreCommit={restoreCommit}
|
||
onPreviewCommitFile={previewCommitFileFromHistory}
|
||
onCreateBranchFromCommit={openNewBranchDialog}
|
||
onCherryPickCommit={cherryPickFromCommit}
|
||
onRevertCommit={revertHistoryCommit}
|
||
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>
|
||
<span class="ahead">↑ {status?.ahead ?? 0}</span>
|
||
<span class="behind">↓ {status?.behind ?? 0}</span>
|
||
<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 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}
|
||
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}
|
||
/>
|
||
{/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}
|
||
|
||
<!-- Create a branch from a specific commit in the history -->
|
||
{#if newBranchCommit}
|
||
<NewBranchDialog
|
||
commit={newBranchCommit}
|
||
{isBusy}
|
||
onCreate={createBranchFromCommit}
|
||
onClose={() => { newBranchCommit = null; }}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Rename a local branch from the branch context menu -->
|
||
{#if renameBranchTarget}
|
||
<RenameBranchDialog
|
||
branch={renameBranchTarget}
|
||
{isBusy}
|
||
onRename={submitRenameBranch}
|
||
onClose={() => { renameBranchTarget = null; }}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- 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}
|
||
|
||
<!-- Compare: pick the two commits to diff -->
|
||
{#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 the two commits to diff -->
|
||
{#if compareSelectOpen}
|
||
<CompareSelectDialog
|
||
{commits}
|
||
{compareFrom}
|
||
{compareTo}
|
||
{canCompare}
|
||
{isBusy}
|
||
{operation}
|
||
onCompareFromChange={(val) => { compareFrom = val; }}
|
||
onCompareToChange={(val) => { compareTo = val; }}
|
||
onCompare={compareSelectedCommits}
|
||
onClose={() => { compareSelectOpen = false; }}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
|
||
{#if compareDialogOpen && comparison}
|
||
{#await import("./lib/components/CompareDialog.svelte") then module}
|
||
<module.default
|
||
{comparison}
|
||
{selectedDiffPath}
|
||
{isBusy}
|
||
highlightQuery={diffHighlightQuery}
|
||
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
|
||
onClose={closeCompareDialog}
|
||
onRestore={restorePreviewedCommitFile}
|
||
onSelectFile={selectDiffFile}
|
||
/>
|
||
{/await}
|
||
{/if}
|
||
|
||
<!-- Credential dialog for push/pull -->
|
||
{#if credDialogOpen && credDialogAction}
|
||
<CredentialDialog
|
||
action={credDialogAction}
|
||
error={credDialogError}
|
||
{isBusy}
|
||
onSubmit={handleCredentialSubmit}
|
||
onCancel={() => { credDialogOpen = false; credDialogAction = null; credDialogError = ""; credDialogKey = null; }}
|
||
/>
|
||
{/if}
|
||
|
||
<!-- 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}
|
||
onClose={() => { resolveDialogOpen = false; }}
|
||
onSelectFile={selectConflictFile}
|
||
onMarkResolved={handleMarkResolved}
|
||
onApply={applyPreparedResolutions}
|
||
/>
|
||
{/await}
|
||
{/if}
|