import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git"; import type { WorkspaceMeta } from "@yaakapp-internal/models"; import classNames from "classnames"; import { useAtomValue } from "jotai"; import type { HTMLAttributes } from "react"; import { forwardRef, useCallback, useMemo } from "react"; import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings"; import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace"; import { useKeyValue } from "../../hooks/useKeyValue"; import { useRandomKey } from "../../hooks/useRandomKey"; import { sync } from "../../init/sync"; import { showConfirm, showConfirmDelete } from "../../lib/confirm"; import { fireAndForget } from "../../lib/fireAndForget"; import { showDialog } from "../../lib/dialog"; import { gitWorktreeStatusAtom } from "../../lib/gitWorktreeStatus"; import { showPrompt } from "../../lib/prompt"; import { showErrorToast, showToast } from "../../lib/toast"; import type { DropdownItem } from "../core/Dropdown"; import { Dropdown } from "../core/Dropdown"; import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui"; import { useGitCallbacks } from "./callbacks"; import { GitCommitDialog } from "./GitCommitDialog"; import { GitRemotesDialog } from "./GitRemotesDialog"; import { handlePullResult, handlePushResult } from "./git-util"; import { HistoryDialog } from "./HistoryDialog"; const EMPTY_BRANCHES: string[] = []; export function GitDropdown() { const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom); if (workspaceMeta == null) return null; if (workspaceMeta.settingSyncDir == null) { return ; } return ; } function SyncDropdownWithSyncDir({ syncDir }: { syncDir: string }) { const workspace = useAtomValue(activeWorkspaceAtom); const worktreeStatus = useAtomValue(gitWorktreeStatusAtom); const [refreshKey, regenerateKey] = useRandomKey(); const branchInfo = useGitBranchInfo(syncDir, refreshKey); const callbacks = useGitCallbacks(syncDir); const { createBranch, deleteBranch, deleteRemoteBranch, renameBranch, mergeBranch, push, pull, checkout, resetChanges, init, } = useGitMutations(syncDir, callbacks); const localBranches = branchInfo.data?.localBranches ?? EMPTY_BRANCHES; const remoteBranches = branchInfo.data?.remoteBranches ?? EMPTY_BRANCHES; const remoteOnlyBranches = useMemo( () => remoteBranches.filter((b) => !localBranches.includes(b.replace(/^origin\//, ""))), [localBranches, remoteBranches], ); const currentBranch = branchInfo.data?.headRefShorthand; const hasChanges = worktreeStatus?.entries.some((e) => e.status !== "current") ?? false; const ahead = branchInfo.data?.ahead ?? 0; const behind = branchInfo.data?.behind ?? 0; const initRepo = useCallback(() => { init.mutate(); }, [init]); const items: DropdownItem[] = useMemo(() => { if (workspace == null || branchInfo.data == null) return []; const tryCheckout = (branch: string, force: boolean) => { checkout.mutate( { branch, force }, { disableToastError: true, async onError(err) { if (!force) { // Checkout failed so ask user if they want to force it const forceCheckout = await showConfirm({ id: "git-force-checkout", title: "Conflicts Detected", description: "Your branch has conflicts. Either make a commit or force checkout to discard changes.", confirmText: "Force Checkout", color: "warning", }); if (forceCheckout) { tryCheckout(branch, true); } } else { // Checkout failed showErrorToast({ id: "git-checkout-error", title: "Error checking out branch", message: String(err), }); } }, async onSuccess(branchName) { showToast({ id: "git-checkout-success", message: ( <> Switched branch {branchName} ), color: "success", }); await sync({ force: true }); }, }, ); }; return [ { label: "View History...", leftSlot: , onSelect: async () => { showDialog({ id: "git-history", size: "md", title: "Commit History", noPadding: true, render: () => , }); }, }, { label: "Manage Remotes...", leftSlot: , onSelect: () => GitRemotesDialog.show(syncDir), }, { type: "separator" }, { label: "New Branch...", leftSlot: , async onSelect() { const name = await showPrompt({ id: "git-branch-name", title: "Create Branch", label: "Branch Name", }); if (!name) return; await createBranch.mutateAsync( { branch: name }, { disableToastError: true, onError: (err) => { showErrorToast({ id: "git-branch-error", title: "Error creating branch", message: String(err), }); }, }, ); tryCheckout(name, false); }, }, { type: "separator" }, { label: "Push", leftSlot: , waitForOnSelect: true, async onSelect() { await push.mutateAsync(undefined, { disableToastError: true, onSuccess: handlePushResult, onError(err) { showErrorToast({ id: "git-push-error", title: "Error pushing changes", message: String(err), }); }, }); }, }, { label: "Pull", leftSlot: , waitForOnSelect: true, async onSelect() { await pull.mutateAsync(undefined, { disableToastError: true, onSuccess: handlePullResult, onError(err) { showErrorToast({ id: "git-pull-error", title: "Error pulling changes", message: String(err), }); }, }); }, }, { label: "Commit...", leftSlot: , onSelect() { showDialog({ id: "commit", title: "Commit Changes", size: "full", noPadding: true, render: ({ hide }) => ( ), }); }, }, { label: "Reset Changes", hidden: !hasChanges, leftSlot: , color: "danger", async onSelect() { const confirmed = await showConfirm({ id: "git-reset-changes", title: "Reset Changes", description: "This will discard all uncommitted changes. This cannot be undone.", confirmText: "Reset", color: "danger", }); if (!confirmed) return; await resetChanges.mutateAsync(undefined, { disableToastError: true, onSuccess() { showToast({ id: "git-reset-success", message: "Changes have been reset", color: "success", }); fireAndForget(sync({ force: true })); }, onError(err) { showErrorToast({ id: "git-reset-error", title: "Error resetting changes", message: String(err), }); }, }); }, }, { type: "separator", label: "Branches", hidden: localBranches.length < 1 }, ...localBranches.map((branch) => { const isCurrent = currentBranch === branch; return { label: branch, leftSlot: , submenuOpenOnClick: true, submenu: [ { label: "Checkout", hidden: isCurrent, onSelect: () => tryCheckout(branch, false), }, { label: ( <> Merge into {currentBranch} ), hidden: isCurrent, async onSelect() { await mergeBranch.mutateAsync( { branch }, { disableToastError: true, onSuccess() { showToast({ id: "git-merged-branch", message: ( <> Merged {branch} into{" "} {currentBranch} ), }); fireAndForget(sync({ force: true })); }, onError(err) { showErrorToast({ id: "git-merged-branch-error", title: "Error merging branch", message: String(err), }); }, }, ); }, }, { label: "New Branch...", async onSelect() { const name = await showPrompt({ id: "git-new-branch-from", title: "New Branch", description: ( <> Create a new branch from {branch} ), label: "Branch Name", }); if (!name) return; await createBranch.mutateAsync( { branch: name, base: branch }, { disableToastError: true, onError: (err) => { showErrorToast({ id: "git-branch-error", title: "Error creating branch", message: String(err), }); }, }, ); tryCheckout(name, false); }, }, { label: "Rename...", async onSelect() { const newName = await showPrompt({ id: "git-rename-branch", title: "Rename Branch", label: "New Branch Name", defaultValue: branch, }); if (!newName || newName === branch) return; await renameBranch.mutateAsync( { oldName: branch, newName }, { disableToastError: true, onSuccess() { showToast({ id: "git-rename-branch-success", message: ( <> Renamed {branch} to{" "} {newName} ), color: "success", }); }, onError(err) { showErrorToast({ id: "git-rename-branch-error", title: "Error renaming branch", message: String(err), }); }, }, ); }, }, { type: "separator", hidden: isCurrent }, { label: "Delete", color: "danger", hidden: isCurrent, onSelect: async () => { const confirmed = await showConfirmDelete({ id: "git-delete-branch", title: "Delete Branch", description: ( <> Permanently delete {branch}? ), }); if (!confirmed) { return; } const result = await deleteBranch.mutateAsync( { branch }, { disableToastError: true, onError(err) { showErrorToast({ id: "git-delete-branch-error", title: "Error deleting branch", message: String(err), }); }, }, ); if (result.type === "not_fully_merged") { const confirmed = await showConfirm({ id: "force-branch-delete", title: "Branch not fully merged", description: ( <>

Branch {branch} is not fully merged.

Do you want to delete it anyway?

), }); if (confirmed) { await deleteBranch.mutateAsync( { branch, force: true }, { disableToastError: true, onError(err) { showErrorToast({ id: "git-force-delete-branch-error", title: "Error force deleting branch", message: String(err), }); }, }, ); } } }, }, ], } satisfies DropdownItem; }), ...remoteOnlyBranches.map((branch) => { const isCurrent = currentBranch === branch; return { label: branch, leftSlot: , submenuOpenOnClick: true, submenu: [ { label: "Checkout", hidden: isCurrent, onSelect: () => tryCheckout(branch, false), }, { label: "Delete", color: "danger", async onSelect() { const confirmed = await showConfirmDelete({ id: "git-delete-remote-branch", title: "Delete Remote Branch", description: ( <> Permanently delete {branch} from the remote? ), }); if (!confirmed) return; await deleteRemoteBranch.mutateAsync( { branch }, { disableToastError: true, onSuccess() { showToast({ id: "git-delete-remote-branch-success", message: ( <> Deleted remote branch {branch} ), color: "success", }); }, onError(err) { showErrorToast({ id: "git-delete-remote-branch-error", title: "Error deleting remote branch", message: String(err), }); }, }, ); }, }, ], } satisfies DropdownItem; }), ]; }, [ branchInfo.data, checkout, createBranch, currentBranch, deleteBranch, deleteRemoteBranch, hasChanges, localBranches, mergeBranch, pull, push, remoteOnlyBranches, renameBranch, resetChanges, syncDir, workspace, ]); if (workspace == null) { return null; } const noRepo = branchInfo.error?.includes("not found"); if (noRepo) { return ; } // Still loading if (branchInfo.data == null) { return null; } return ( {currentBranch}
{ahead > 0 && ( {ahead} )} {behind > 0 && ( {behind} )}
); } const GitMenuButton = forwardRef>( function GitMenuButton({ className, ...props }: HTMLAttributes, ref) { return (