diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 24cffb2c..773b556e 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -70,7 +70,7 @@ import { generateCommitMessage as generateSessionCommitMessage, getGitWorktreeBo import { sessionEvents } from '@/lib/sessionEvents'; import { useI18n } from '@/lib/i18n'; -type SyncAction = 'fetch' | 'pull' | 'push' | null; +type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; type CommitAction = 'commit' | 'commitAndPush' | null; type BranchOperation = 'merge' | 'rebase' | null; type ActionTab = 'commit' | 'branch' | 'pr' | 'worktree'; @@ -911,6 +911,18 @@ export const GitView: React.FC = () => { setSyncAction(action); try { + const getPullOptions = (pullRemote: GitRemote) => { + const trackingPrefix = `${pullRemote.name}/`; + const trackedBranch = status?.tracking?.startsWith(trackingPrefix) + ? status.tracking.slice(trackingPrefix.length) + : undefined; + return { + remote: pullRemote.name, + branch: trackedBranch, + rebase: true, + }; + }; + if (action === 'fetch') { if (!remote) { throw new Error('No remote available for fetch'); @@ -921,7 +933,7 @@ export const GitView: React.FC = () => { if (!remote) { throw new Error('No remote available for pull'); } - const result = await git.gitPull(currentDirectory, { remote: remote.name }); + const result = await git.gitPull(currentDirectory, getPullOptions(remote)); toast.success( result.files.length === 1 ? t('gitView.toast.pulledFilesSingle', { count: result.files.length, name: remote.name }) @@ -930,6 +942,26 @@ export const GitView: React.FC = () => { } else if (action === 'push') { await git.gitPush(currentDirectory); toast.success(t('gitView.toast.pushedToUpstream')); + } else if (action === 'sync') { + if (!remote) { + throw new Error('No remote available for sync'); + } + await git.gitFetch(currentDirectory, { remote: remote.name }); + const afterFetch = await git.getGitStatus(currentDirectory); + + if ((afterFetch.behind ?? 0) > 0) { + if ((afterFetch.files?.length ?? 0) > 0) { + toast.error(t('gitView.toast.commitOrStashBeforeSync')); + return; + } + await git.gitPull(currentDirectory, getPullOptions(remote)); + } + + const afterPull = await git.getGitStatus(currentDirectory); + if ((afterPull.ahead ?? 0) > 0) { + await git.gitPush(currentDirectory); + } + toast.success(t('gitView.toast.syncedChanges')); } await refreshStatusAndBranches(false); @@ -938,7 +970,7 @@ export const GitView: React.FC = () => { const message = err instanceof Error ? err.message - : t('gitView.toast.syncActionFailed', { action: action === 'pull' ? t('gitView.sync.pull') : action }); + : t('gitView.toast.syncActionFailed', { action: action === 'sync' ? t('gitView.sync.syncChanges') : action === 'pull' ? t('gitView.sync.pull') : action }); toast.error(message); } finally { setSyncAction(null); @@ -2025,8 +2057,7 @@ export const GitView: React.FC = () => { syncAction={syncAction} remotes={effectiveRemotes} onFetch={(remote) => handleSyncAction('fetch', remote)} - onPull={(remote) => handleSyncAction('pull', remote)} - onPush={() => handleSyncAction('push')} + onSync={(remote) => handleSyncAction('sync', remote)} onRemoveRemote={handleRemoveRemote} removingRemoteName={removingRemoteName} onCheckoutBranch={handleCheckoutBranch} @@ -2124,17 +2155,18 @@ export const GitView: React.FC = () => { /> ) : ( - 0 ? (status?.behind ?? 0) : 0} - isPulling={syncAction === 'pull'} - onPull={() => { - const remote = effectiveRemotes[0]; - if (!remote) { - return; - } - void handleSyncAction('pull', remote); - }} - /> + 0 ? (status?.ahead ?? 0) : 0} + behind={effectiveRemotes.length > 0 ? (status?.behind ?? 0) : 0} + isSyncing={syncAction === 'sync'} + onSync={() => { + const remote = effectiveRemotes[0]; + if (!remote) { + return; + } + void handleSyncAction('sync', remote); + }} + /> )} ) : null} diff --git a/packages/ui/src/components/views/git/GitEmptyState.tsx b/packages/ui/src/components/views/git/GitEmptyState.tsx index 7090a3a2..ba039ced 100644 --- a/packages/ui/src/components/views/git/GitEmptyState.tsx +++ b/packages/ui/src/components/views/git/GitEmptyState.tsx @@ -1,20 +1,23 @@ import React from 'react'; -import { RiGitCommitLine, RiArrowDownLine, RiLoader4Line } from '@remixicon/react'; +import { RiGitCommitLine, RiRefreshLine, RiLoader4Line } from '@remixicon/react'; import { Button } from '@/components/ui/button'; import { useI18n } from '@/lib/i18n'; interface GitEmptyStateProps { + ahead: number; behind: number; - onPull: () => void; - isPulling: boolean; + onSync: () => void; + isSyncing: boolean; } export const GitEmptyState: React.FC = ({ + ahead, behind, - onPull, - isPulling, + onSync, + isSyncing, }) => { const { t } = useI18n(); + const hasSyncChanges = ahead > 0 || behind > 0; return (
@@ -25,20 +28,18 @@ export const GitEmptyState: React.FC = ({ {t('gitView.empty.cleanDescription')}

- {behind > 0 && ( + {hasSyncChanges && ( )}
diff --git a/packages/ui/src/components/views/git/GitHeader.tsx b/packages/ui/src/components/views/git/GitHeader.tsx index 5fba2a05..6008e905 100644 --- a/packages/ui/src/components/views/git/GitHeader.tsx +++ b/packages/ui/src/components/views/git/GitHeader.tsx @@ -26,7 +26,7 @@ import { SyncActions } from './SyncActions'; import type { GitStatus, GitIdentityProfile, GitRemote } from '@/lib/api/types'; import { useI18n } from '@/lib/i18n'; -type SyncAction = 'fetch' | 'pull' | 'push' | null; +type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; interface GitHeaderProps { status: GitStatus | null; @@ -36,8 +36,7 @@ interface GitHeaderProps { syncAction: SyncAction; remotes: GitRemote[]; onFetch: (remote: GitRemote) => void; - onPull: (remote: GitRemote) => void; - onPush: () => void; + onSync: (remote: GitRemote) => void; onRemoveRemote: (remote: GitRemote) => void; removingRemoteName: string | null; onCheckoutBranch: (branch: string) => void; @@ -195,8 +194,7 @@ export const GitHeader: React.FC = ({ syncAction, remotes, onFetch, - onPull, - onPush, + onSync, onRemoveRemote, removingRemoteName, onCheckoutBranch, @@ -239,8 +237,7 @@ export const GitHeader: React.FC = ({ syncAction={syncAction} remotes={remotes} onFetch={onFetch} - onPull={onPull} - onPush={onPush} + onSync={onSync} onRemoveRemote={onRemoveRemote} removingRemoteName={removingRemoteName} disabled={!status} @@ -248,6 +245,8 @@ export const GitHeader: React.FC = ({ aheadCount={status.ahead} behindCount={status.behind} + trackingRemoteName={status.tracking?.split('/')[0]} + hasUncommittedChanges={(status.files?.length ?? 0) > 0} /> ); diff --git a/packages/ui/src/components/views/git/SyncActions.tsx b/packages/ui/src/components/views/git/SyncActions.tsx index f3d32a0d..f32de47a 100644 --- a/packages/ui/src/components/views/git/SyncActions.tsx +++ b/packages/ui/src/components/views/git/SyncActions.tsx @@ -1,10 +1,9 @@ import React from 'react'; import { - RiRefreshLine, - RiArrowDownLine, - RiArrowUpLine, + RiArrowDownSLine, RiCloseLine, RiLoader4Line, + RiRefreshLine, } from '@remixicon/react'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; @@ -16,140 +15,98 @@ import { } from '@/components/ui/dropdown-menu'; import type { GitRemote } from '@/lib/gitApi'; import { useI18n } from '@/lib/i18n'; +import { cn } from '@/lib/utils'; -type SyncAction = 'fetch' | 'pull' | 'push' | null; +type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null; interface SyncActionsProps { syncAction: SyncAction; remotes: GitRemote[]; onFetch: (remote: GitRemote) => void; - onPull: (remote: GitRemote) => void; - onPush: () => void; + onSync: (remote: GitRemote) => void; onRemoveRemote?: (remote: GitRemote) => void; disabled: boolean; removingRemoteName?: string | null; iconOnly?: boolean; aheadCount?: number; behindCount?: number; + trackingRemoteName?: string; + hasUncommittedChanges?: boolean; } export const SyncActions: React.FC = ({ syncAction, remotes = [], onFetch, - onPull, - onPush, + onSync, onRemoveRemote, disabled, removingRemoteName = null, - iconOnly = false, aheadCount = 0, behindCount = 0, + trackingRemoteName, + hasUncommittedChanges = false, }) => { const { t } = useI18n(); const skipRemoteSelectRef = React.useRef(false); - const hasNoRemotes = remotes.length === 0; const isRemovingRemote = Boolean(removingRemoteName); - const isDisabled = disabled || syncAction !== null || isRemovingRemote || hasNoRemotes; - const hasMultipleRemotes = remotes.length > 1; + const trackingRemote = remotes.find((remote) => remote.name === trackingRemoteName) ?? remotes[0]; + const blocksRebaseSync = behindCount > 0 && hasUncommittedChanges; + const isPrimaryDisabled = disabled || syncAction !== null || isRemovingRemote || !trackingRemote || blocksRebaseSync; + const isDropdownDisabled = disabled || syncAction !== null || isRemovingRemote || remotes.length === 0; + const countsLabel = t('gitView.sync.syncCounts', { ahead: aheadCount, behind: behindCount }); + const tooltipLabel = blocksRebaseSync + ? t('gitView.sync.commitOrStashTooltip') + : trackingRemote + ? t('gitView.sync.syncChangesTooltip', { ahead: aheadCount, behind: behindCount }) + : t('gitView.sync.noRemoteTooltip'); - const handleFetch = () => { - const remote = remotes[0]; - if (remotes.length === 1 && remote) { - onFetch(remote); + const handleSync = () => { + if (!trackingRemote) { + return; } + onSync(trackingRemote); }; - const handlePull = () => { - const remote = remotes[0]; - if (remotes.length === 1 && remote) { - onPull(remote); - } - }; - - const handlePush = () => { - if (remotes.length >= 1) { - onPush(); - } - }; - - const renderButton = ( - action: SyncAction, - icon: React.ReactNode, - loadingIcon: React.ReactNode, - label: string, - onClick: () => void, - tooltipText: string, - counter?: number - ) => { - const button = ( - - ); - - return ( + return ( +
- {button} - {tooltipText} + + + + {tooltipLabel} - ); - }; - const renderDropdownButton = ( - action: SyncAction, - icon: React.ReactNode, - loadingIcon: React.ReactNode, - label: string, - onSelect: (remote: GitRemote) => void, - tooltipText: string, - counter?: number - ) => { - return ( - - - - - - - {tooltipText} - + + + {remotes.map((remote) => ( = ({ skipRemoteSelectRef.current = false; return; } - onSelect(remote); + onFetch(remote); }} >
+
- {remote.name} + {t('gitView.sync.fetchFromRemote', { name: remote.name })} {remote.fetchUrl}
- {onRemoveRemote ? ( + {onRemoveRemote && remote.name !== trackingRemoteName ? (