import { useGit } 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 } 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 { showDialog } from "../../lib/dialog"; import { fireAndForget } from "../../lib/fireAndForget"; import { showPrompt } from "../../lib/prompt"; import { showErrorToast, showToast } from "../../lib/toast"; import { Banner } from "../core/Banner"; import type { DropdownItem } from "../core/Dropdown"; import { Dropdown } from "../core/Dropdown"; import { Icon } from "../core/Icon"; import { InlineCode } from "../core/InlineCode"; import { gitCallbacks } from "./callbacks"; import { GitCommitDialog } from "./GitCommitDialog"; import { GitRemotesDialog } from "./GitRemotesDialog"; import { handlePullResult, handlePushResult } from "./git-util"; import { HistoryDialog } from "./HistoryDialog"; 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 [refreshKey, regenerateKey] = useRandomKey(); const [ { status, log }, { createBranch, deleteBranch, deleteRemoteBranch, renameBranch, mergeBranch, push, pull, checkout, resetChanges, init, }, ] = useGit(syncDir, gitCallbacks(syncDir), refreshKey); const localBranches = status.data?.localBranches ?? []; const remoteBranches = status.data?.remoteBranches ?? []; const remoteOnlyBranches = remoteBranches.filter( (b) => !localBranches.includes(b.replace(/^origin\//, "")), ); if (workspace == null) { return null; } const noRepo = status.error?.includes("not found"); if (noRepo) { return ; } // Still loading if (status.data == null) { return null; } const currentBranch = status.data.headRefShorthand; const hasChanges = status.data.entries.some((e) => e.status !== "current"); const _hasRemotes = (status.data.origins ?? []).length > 0; const { ahead, behind } = status.data; 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 }); }, }, ); }; const items: DropdownItem[] = [ { label: "View History...", hidden: (log.data ?? []).length === 0, 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; }), ]; return ( {currentBranch}
{ahead > 0 && ( {ahead} )} {behind > 0 && ( {behind} )}
); } const GitMenuButton = forwardRef>( function GitMenuButton({ className, ...props }: HTMLAttributes, ref) { return (