From c80c2b62a84eaa021c56dd81562c009b92bccc15 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Tue, 5 May 2026 20:45:21 +0300 Subject: [PATCH] feat: add one-click git sync button Combine fetch, pull with rebase, and push into one sync action Keep remote dropdown focused on safe fetch actions Block sync when uncommitted changes would conflict with rebase --- packages/ui/src/components/views/GitView.tsx | 64 +++-- .../components/views/git/GitEmptyState.tsx | 29 +-- .../ui/src/components/views/git/GitHeader.tsx | 13 +- .../src/components/views/git/SyncActions.tsx | 226 +++++------------- packages/ui/src/lib/api/types.ts | 8 +- packages/ui/src/lib/gitApi.ts | 2 +- packages/ui/src/lib/gitApiHttp.ts | 3 +- packages/ui/src/lib/i18n/messages/en.ts | 10 + packages/ui/src/lib/i18n/messages/es.ts | 10 + packages/ui/src/lib/i18n/messages/ko.ts | 10 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 10 + packages/ui/src/lib/i18n/messages/uk.ts | 10 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 10 + packages/vscode/src/bridge-git-runtime.ts | 5 +- packages/vscode/src/gitService.ts | 8 +- packages/vscode/webview/api/git.ts | 3 +- packages/web/server/lib/git/service.js | 5 +- 17 files changed, 217 insertions(+), 209 deletions(-) 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 ? (