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 { sync } from '../../init/sync'; import { showConfirm, showConfirmDelete } from '../../lib/confirm'; import { showDialog } from '../../lib/dialog'; 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 { BranchSelectionDialog } from './BranchSelectionDialog'; import { gitCallbacks } from './callbacks'; import { GitCommitDialog } from './GitCommitDialog'; import { GitRemotesDialog } from './GitRemotesDialog'; import { handlePullResult } 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 [ { status, log }, { branch, deleteBranch, fetchAll, mergeBranch, push, pull, checkout, init }, ] = useGit(syncDir, gitCallbacks(syncDir)); const localBranches = status.data?.localBranches ?? []; const remoteBranches = status.data?.remoteBranches ?? []; const remoteOnlyBranches = remoteBranches.filter( (b) => !localBranches.includes(b.replace(/^origin\//, '')), ); const currentBranch = status.data?.headRefShorthand ?? 'UNKNOWN'; if (workspace == null) { return null; } const noRepo = status.error?.includes('not found'); if (noRepo) { 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 }); }, }, ); }; 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 branch.mutateAsync( { branch: name }, { disableToastError: true, onError: (err) => { showErrorToast({ id: 'git-branch-error', title: 'Error creating branch', message: String(err), }); }, }, ); tryCheckout(name, false); }, }, { label: 'Merge Branch', leftSlot: , hidden: localBranches.length <= 1, async onSelect() { showDialog({ id: 'git-merge', title: 'Merge Branch', size: 'sm', description: ( <> Select a branch to merge into {currentBranch} ), render: ({ hide }) => ( b !== currentBranch)} onCancel={hide} onSelect={async (branch) => { await mergeBranch.mutateAsync( { branch, force: false }, { disableToastError: true, onSettled: hide, onSuccess() { showToast({ id: 'git-merged-branch', message: ( <> Merged {branch} into{' '} {currentBranch} ), }); sync({ force: true }); }, onError(err) { showErrorToast({ id: 'git-merged-branch-error', title: 'Error merging branch', message: String(err), }); }, }, ); }} /> ), }); }, }, { label: 'Delete Branch', leftSlot: , hidden: localBranches.length <= 1, color: 'danger', async onSelect() { if (currentBranch == null) return; const confirmed = await showConfirmDelete({ id: 'git-delete-branch', title: 'Delete Branch', description: ( <> Permanently delete {currentBranch}? ), }); if (confirmed) { await deleteBranch.mutateAsync( { branch: currentBranch }, { disableToastError: true, onError(err) { showErrorToast({ id: 'git-delete-branch-error', title: 'Error deleting branch', message: String(err), }); }, async onSuccess() { await sync({ force: true }); }, }, ); } }, }, { type: 'separator' }, { label: 'Push', leftSlot: , waitForOnSelect: true, async onSelect() { await push.mutateAsync(undefined, { disableToastError: true, onSuccess: handlePullResult, onError(err) { showErrorToast({ id: 'git-push-error', title: 'Error pushing changes', message: String(err), }); }, }); }, }, { label: 'Pull', hidden: (status.data?.origins ?? []).length === 0, 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', className: '!max-h-[min(80vh,40rem)] !max-w-[min(50rem,90vw)]', render: ({ hide }) => ( ), }); }, }, { type: 'separator', label: 'Branches', hidden: localBranches.length < 1 }, ...localBranches.map((branch) => { const isCurrent = currentBranch === branch; return { label: branch, leftSlot: , onSelect: isCurrent ? undefined : () => tryCheckout(branch, false), }; }), ...remoteOnlyBranches.map((branch) => { const isCurrent = currentBranch === branch; return { label: branch, leftSlot: , onSelect: isCurrent ? undefined : () => tryCheckout(branch, false), }; }), ]; return ( {currentBranch} ); } const GitMenuButton = forwardRef>( function GitMenuButton({ className, ...props }: HTMLAttributes, ref) { return (