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
This commit is contained in:
Bohdan Triapitsyn
2026-05-05 20:45:31 +03:00
parent ddfd2db0b8
commit c80c2b62a8
17 changed files with 217 additions and 209 deletions
+48 -16
View File
@@ -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 = () => {
/>
</>
) : (
<GitEmptyState
behind={effectiveRemotes.length > 0 ? (status?.behind ?? 0) : 0}
isPulling={syncAction === 'pull'}
onPull={() => {
const remote = effectiveRemotes[0];
if (!remote) {
return;
}
void handleSyncAction('pull', remote);
}}
/>
<GitEmptyState
ahead={effectiveRemotes.length > 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);
}}
/>
)}
</div>
) : null}
@@ -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<GitEmptyStateProps> = ({
ahead,
behind,
onPull,
isPulling,
onSync,
isSyncing,
}) => {
const { t } = useI18n();
const hasSyncChanges = ahead > 0 || behind > 0;
return (
<div className="flex flex-col items-center justify-center py-10 px-4 text-center">
<RiGitCommitLine className="size-10 text-muted-foreground/70 mb-4" />
@@ -25,20 +28,18 @@ export const GitEmptyState: React.FC<GitEmptyStateProps> = ({
{t('gitView.empty.cleanDescription')}
</p>
{behind > 0 && (
{hasSyncChanges && (
<Button
variant="outline"
onClick={onPull}
disabled={isPulling}
variant="default"
onClick={onSync}
disabled={isSyncing}
>
{isPulling ? (
{isSyncing ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiArrowDownLine className="size-4" />
<RiRefreshLine className="size-4" />
)}
{behind === 1
? t('gitView.empty.pullBehindSingle', { count: behind })
: t('gitView.empty.pullBehindPlural', { count: behind })}
{t('gitView.sync.syncCounts', { ahead, behind })}
</Button>
)}
</div>
@@ -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<GitHeaderProps> = ({
syncAction,
remotes,
onFetch,
onPull,
onPush,
onSync,
onRemoveRemote,
removingRemoteName,
onCheckoutBranch,
@@ -239,8 +237,7 @@ export const GitHeader: React.FC<GitHeaderProps> = ({
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<GitHeaderProps> = ({
aheadCount={status.ahead}
behindCount={status.behind}
trackingRemoteName={status.tracking?.split('/')[0]}
hasUncommittedChanges={(status.files?.length ?? 0) > 0}
/>
);
@@ -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<SyncActionsProps> = ({
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 = (
<Button
variant="ghost"
size="sm"
className={iconOnly ? 'relative h-8 w-8 px-0' : 'h-8 px-2'}
onClick={onClick}
disabled={isDisabled}
>
{syncAction === action ? loadingIcon : icon}
{!iconOnly && <span className="git-header-label">{label}</span>}
{!iconOnly && typeof counter === 'number' && counter > 0 ? (
<span className="rounded-sm bg-interactive-selection/40 px-1 text-[10px] leading-4 text-foreground tabular-nums">
{counter}
</span>
) : null}
{iconOnly && typeof counter === 'number' && counter > 0 ? (
<span className="absolute -right-1 -top-1 min-w-[1rem] rounded-full bg-interactive-selection px-1 text-[10px] leading-4 text-interactive-selection-foreground tabular-nums">
{counter}
</span>
) : null}
</Button>
);
return (
return (
<div className="inline-flex items-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] border border-border/60 bg-[var(--surface-elevated)] overflow-hidden">
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleSync}
disabled={isPrimaryDisabled}
className={cn(
'inline-flex h-7 items-center gap-1.5 px-2 typography-ui-label font-medium text-foreground',
'transition-colors hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50'
)}
aria-label={t('gitView.sync.syncChanges')}
>
{syncAction === 'sync' ? (
<RiLoader4Line className="size-4 animate-spin" />
) : (
<RiRefreshLine className="size-4" />
)}
<span className="whitespace-nowrap tabular-nums">{countsLabel}</span>
</button>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltipLabel}</TooltipContent>
</Tooltip>
);
};
const renderDropdownButton = (
action: SyncAction,
icon: React.ReactNode,
loadingIcon: React.ReactNode,
label: string,
onSelect: (remote: GitRemote) => void,
tooltipText: string,
counter?: number
) => {
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className={iconOnly ? 'relative h-8 w-8 px-0' : 'h-8 px-2'}
disabled={isDisabled}
>
{syncAction === action ? loadingIcon : icon}
{!iconOnly && <span className="git-header-label">{label}</span>}
{!iconOnly && typeof counter === 'number' && counter > 0 ? (
<span className="rounded-sm bg-interactive-selection/40 px-1 text-[10px] leading-4 text-foreground tabular-nums">
{counter}
</span>
) : null}
{iconOnly && typeof counter === 'number' && counter > 0 ? (
<span className="absolute -right-1 -top-1 min-w-[1rem] rounded-full bg-interactive-selection px-1 text-[10px] leading-4 text-interactive-selection-foreground tabular-nums">
{counter}
</span>
) : null}
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent sideOffset={8}>{tooltipText}</TooltipContent>
</Tooltip>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
'inline-flex h-7 w-6 items-center justify-center border-l border-[var(--interactive-border)] text-muted-foreground',
'transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50'
)}
disabled={isDropdownDisabled}
aria-label={t('gitView.sync.moreActionsAria')}
>
<RiArrowDownSLine className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" alignOffset={-40} className="w-[min(360px,calc(100vw-2rem))] max-h-[320px] overflow-y-auto">
{remotes.map((remote) => (
<DropdownMenuItem
@@ -160,21 +117,22 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
skipRemoteSelectRef.current = false;
return;
}
onSelect(remote);
onFetch(remote);
}}
>
<div className="flex w-full items-center gap-2">
<RiRefreshLine className="size-4 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex flex-col">
<span className="typography-ui-label text-foreground">
{remote.name}
{t('gitView.sync.fetchFromRemote', { name: remote.name })}
</span>
<span className="typography-meta text-muted-foreground truncate">
{remote.fetchUrl}
</span>
</div>
</div>
{onRemoveRemote ? (
{onRemoveRemote && remote.name !== trackingRemoteName ? (
<Button
type="button"
variant="destructive"
@@ -207,64 +165,6 @@ export const SyncActions: React.FC<SyncActionsProps> = ({
))}
</DropdownMenuContent>
</DropdownMenu>
);
};
return (
<div className="flex items-center gap-0.5">
{hasMultipleRemotes
? renderDropdownButton(
'fetch',
<RiRefreshLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
t('gitView.sync.fetch'),
onFetch,
t('gitView.sync.fetchTooltip')
)
: renderButton(
'fetch',
<RiRefreshLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
t('gitView.sync.fetch'),
handleFetch,
t('gitView.sync.fetchTooltip')
)}
{hasMultipleRemotes
? renderDropdownButton(
'pull',
<RiArrowDownLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
t('gitView.sync.pull'),
onPull,
behindCount > 0
? t('gitView.sync.pullTooltipBehind', { count: behindCount })
: t('gitView.sync.pullTooltip'),
behindCount
)
: renderButton(
'pull',
<RiArrowDownLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
t('gitView.sync.pull'),
handlePull,
behindCount > 0
? t('gitView.sync.pullTooltipBehind', { count: behindCount })
: t('gitView.sync.pullTooltip'),
behindCount
)}
{renderButton(
'push',
<RiArrowUpLine className="size-4" />,
<RiLoader4Line className="size-4 animate-spin" />,
t('gitView.sync.push'),
handlePush,
aheadCount > 0
? t('gitView.sync.pushTooltipAhead', { count: aheadCount })
: t('gitView.sync.pushTooltip'),
aheadCount
)}
</div>
);
};