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 { 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 }, { createBranch, deleteBranch, deleteRemoteBranch, renameBranch, 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\//, '')), ); 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 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: 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: , 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} ), }); 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} ); } const GitMenuButton = forwardRef>( function GitMenuButton({ className, ...props }: HTMLAttributes, ref) { return (