Files
vibe-kanban/frontend/src/components/tasks/Toolbar/GitOperations.tsx
Alex Netsch 2b8517281c Always enable merge/rebase (vibe-kanban) (#1090)
* Done! I've successfully removed all the branch status conditions that were disabling the merge and rebase buttons:

## Changes Made to `GitOperations.tsx`:

1. **Merge button** (line 433) -  Removed `Boolean((branchStatus.commits_behind ?? 0) > 0) ||`
   - Now enabled regardless of whether branch is behind

2. **PR/Push button** (line 452) -  Removed `Boolean((branchStatus.commits_behind ?? 0) > 0) ||`
   - Now enabled regardless of whether branch is behind

3. **Rebase button** (line 477) -  Removed `(branchStatus.commits_behind ?? 0) === 0`
   - Now enabled regardless of whether branch is behind or not

All three buttons will now be available at all times, with the remaining safety checks still in place:
- Still disabled during active conflicts
- Still disabled when operations are in progress
- Still disabled when attempt is running
- Merge still disabled when there's an open PR

* fmt
2025-10-23 17:29:24 +01:00

490 lines
16 KiB
TypeScript

import {
ArrowRight,
GitBranch as GitBranchIcon,
GitPullRequest,
RefreshCw,
Settings,
AlertTriangle,
CheckCircle,
ExternalLink,
} from 'lucide-react';
import { Button } from '@/components/ui/button.tsx';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip.tsx';
import { useMemo, useState } from 'react';
import type {
BranchStatus,
GitBranch,
TaskAttempt,
TaskWithAttemptStatus,
} from 'shared/types';
import { useRebase } from '@/hooks/useRebase';
import { useMerge } from '@/hooks/useMerge';
import { usePush } from '@/hooks/usePush';
import { useChangeTargetBranch } from '@/hooks/useChangeTargetBranch';
import NiceModal from '@ebay/nice-modal-react';
import { Err } from '@/lib/api';
import type { GitOperationError } from 'shared/types';
import { showModal } from '@/lib/modals';
import { useTranslation } from 'react-i18next';
interface GitOperationsProps {
selectedAttempt: TaskAttempt;
task: TaskWithAttemptStatus;
projectId: string;
branchStatus: BranchStatus | null;
branches: GitBranch[];
isAttemptRunning: boolean;
setError: (error: string | null) => void;
selectedBranch: string | null;
}
export type GitOperationsInputs = Omit<GitOperationsProps, 'selectedAttempt'>;
function GitOperations({
selectedAttempt,
task,
projectId,
branchStatus,
branches,
isAttemptRunning,
setError,
selectedBranch,
}: GitOperationsProps) {
const { t } = useTranslation('tasks');
// Git operation hooks
const rebaseMutation = useRebase(selectedAttempt.id, projectId);
const mergeMutation = useMerge(selectedAttempt.id);
const pushMutation = usePush(selectedAttempt.id);
const changeTargetBranchMutation = useChangeTargetBranch(
selectedAttempt.id,
projectId
);
const isChangingTargetBranch = changeTargetBranchMutation.isPending;
// Git status calculations
const hasConflictsCalculated = useMemo(
() => Boolean((branchStatus?.conflicted_files?.length ?? 0) > 0),
[branchStatus?.conflicted_files]
);
// Local state for git operations
const [merging, setMerging] = useState(false);
const [pushing, setPushing] = useState(false);
const [rebasing, setRebasing] = useState(false);
const [mergeSuccess, setMergeSuccess] = useState(false);
const [pushSuccess, setPushSuccess] = useState(false);
// Target branch change handlers
const handleChangeTargetBranchClick = async (newBranch: string) => {
await changeTargetBranchMutation
.mutateAsync(newBranch)
.then(() => setError(null))
.catch((error) => {
setError(error.message || t('git.errors.changeTargetBranch'));
});
};
const handleChangeTargetBranchDialogOpen = async () => {
try {
const result = await showModal<{
action: 'confirmed' | 'canceled';
branchName: string;
}>('change-target-branch-dialog', {
branches,
isChangingTargetBranch: isChangingTargetBranch,
});
if (result.action === 'confirmed' && result.branchName) {
await handleChangeTargetBranchClick(result.branchName);
}
} catch (error) {
// User cancelled - do nothing
}
};
// Memoize merge status information to avoid repeated calculations
const mergeInfo = useMemo(() => {
if (!branchStatus?.merges)
return {
hasOpenPR: false,
openPR: null,
hasMergedPR: false,
mergedPR: null,
hasMerged: false,
latestMerge: null,
};
const openPR = branchStatus.merges.find(
(m: any) => m.type === 'pr' && m.pr_info.status === 'open'
);
const mergedPR = branchStatus.merges.find(
(m: any) => m.type === 'pr' && m.pr_info.status === 'merged'
);
const merges = branchStatus.merges.filter(
(m: any) =>
m.type === 'direct' ||
(m.type === 'pr' && m.pr_info.status === 'merged')
);
return {
hasOpenPR: !!openPR,
openPR,
hasMergedPR: !!mergedPR,
mergedPR,
hasMerged: merges.length > 0,
latestMerge: branchStatus.merges[0] || null, // Most recent merge
};
}, [branchStatus?.merges]);
const mergeButtonLabel = useMemo(() => {
if (mergeSuccess) return t('git.states.merged');
if (merging) return t('git.states.merging');
return t('git.states.merge');
}, [mergeSuccess, merging, t]);
const rebaseButtonLabel = useMemo(() => {
if (rebasing) return t('git.states.rebasing');
return t('git.states.rebase');
}, [rebasing, t]);
const prButtonLabel = useMemo(() => {
if (mergeInfo.hasOpenPR) {
return pushSuccess
? t('git.states.pushed')
: pushing
? t('git.states.pushing')
: t('git.states.push');
}
return t('git.states.createPr');
}, [mergeInfo.hasOpenPR, pushSuccess, pushing, t]);
const handleMergeClick = async () => {
// Directly perform merge without checking branch status
await performMerge();
};
const handlePushClick = async () => {
try {
setPushing(true);
await pushMutation.mutateAsync();
setError(null); // Clear any previous errors on success
setPushSuccess(true);
setTimeout(() => setPushSuccess(false), 2000);
} catch (error: any) {
setError(error.message || t('git.errors.pushChanges'));
} finally {
setPushing(false);
}
};
const performMerge = async () => {
try {
setMerging(true);
await mergeMutation.mutateAsync();
setError(null); // Clear any previous errors on success
setMergeSuccess(true);
setTimeout(() => setMergeSuccess(false), 2000);
} catch (error) {
// @ts-expect-error it is type ApiError
setError(error.message || t('git.errors.mergeChanges'));
} finally {
setMerging(false);
}
};
const handleRebaseWithNewBranchAndUpstream = async (
newBaseBranch: string,
selectedUpstream: string
) => {
setRebasing(true);
await rebaseMutation
.mutateAsync({
newBaseBranch: newBaseBranch,
oldBaseBranch: selectedUpstream,
})
.then(() => setError(null))
.catch((err: Err<GitOperationError>) => {
const data = err?.error;
const isConflict =
data?.type === 'merge_conflicts' ||
data?.type === 'rebase_in_progress';
if (!isConflict) setError(err.message || t('git.errors.rebaseBranch'));
});
setRebasing(false);
};
const handleRebaseDialogOpen = async () => {
try {
const defaultTargetBranch = selectedAttempt.target_branch;
const result = await showModal<{
action: 'confirmed' | 'canceled';
branchName?: string;
upstreamBranch?: string;
}>('rebase-dialog', {
branches,
isRebasing: rebasing,
initialTargetBranch: defaultTargetBranch,
initialUpstreamBranch: defaultTargetBranch,
});
if (
result.action === 'confirmed' &&
result.branchName &&
result.upstreamBranch
) {
await handleRebaseWithNewBranchAndUpstream(
result.branchName,
result.upstreamBranch
);
}
} catch (error) {
// User cancelled - do nothing
}
};
const handlePRButtonClick = async () => {
// If PR already exists, push to it
if (mergeInfo.hasOpenPR) {
await handlePushClick();
return;
}
NiceModal.show('create-pr', {
attempt: selectedAttempt,
task,
projectId,
});
};
// Hide entire panel only if PR is merged
if (mergeInfo.hasMergedPR) {
return null;
}
return (
<div className="w-full border-b py-2">
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 overflow-hidden">
{/* Left: Branch flow */}
<div className="flex items-center gap-2 min-w-0 shrink-0 overflow-hidden">
{/* Task branch chip */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="hidden sm:inline-flex items-center gap-1.5 max-w-[280px] px-2 py-0.5 rounded-full bg-muted text-xs font-medium min-w-0">
<GitBranchIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{selectedAttempt.branch}</span>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('git.labels.taskBranch')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<ArrowRight className="hidden sm:inline h-4 w-4 text-muted-foreground" />
{/* Target branch chip + change button */}
<div className="flex items-center gap-1 min-w-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-1.5 max-w-[280px] px-2 py-0.5 rounded-full bg-muted text-xs font-medium min-w-0">
<GitBranchIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<span className="truncate">
{branchStatus?.target_branch_name ||
selectedAttempt.target_branch ||
selectedBranch ||
t('git.branch.current')}
</span>
</span>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('rebase.dialog.targetLabel')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="xs"
onClick={handleChangeTargetBranchDialogOpen}
disabled={isAttemptRunning || hasConflictsCalculated}
className="hidden md:inline-flex h-5 w-5 p-0 hover:bg-muted"
aria-label={t('branches.changeTarget.dialog.title')}
>
<Settings className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('branches.changeTarget.dialog.title')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Center: Status chips */}
<div className="flex items-center gap-2 text-xs min-w-0 overflow-hidden whitespace-nowrap">
{(() => {
const commitsAhead = branchStatus?.commits_ahead ?? 0;
const commitsBehind = branchStatus?.commits_behind ?? 0;
if (hasConflictsCalculated) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100/60 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
<AlertTriangle className="h-3.5 w-3.5" />
{t('git.status.conflicts')}
</span>
);
}
if (branchStatus?.is_rebase_in_progress) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100/60 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300">
<RefreshCw className="h-3.5 w-3.5 animate-spin" />
{t('git.states.rebasing')}
</span>
);
}
if (mergeInfo.hasMergedPR) {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-100/70 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300">
<CheckCircle className="h-3.5 w-3.5" />
{t('git.states.merged')}
</span>
);
}
if (mergeInfo.hasOpenPR && mergeInfo.openPR?.type === 'pr') {
const prMerge = mergeInfo.openPR;
return (
<button
onClick={() => window.open(prMerge.pr_info.url, '_blank')}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-sky-100/60 dark:bg-sky-900/30 text-sky-700 dark:text-sky-300 hover:underline truncate max-w-[180px] sm:max-w-none"
aria-label={t('git.pr.open', {
number: Number(prMerge.pr_info.number),
})}
>
<GitPullRequest className="h-3.5 w-3.5" />
{t('git.pr.number', {
number: Number(prMerge.pr_info.number),
})}
<ExternalLink className="h-3.5 w-3.5" />
</button>
);
}
const chips: React.ReactNode[] = [];
if (commitsAhead > 0) {
chips.push(
<span
key="ahead"
className="hidden sm:inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-100/70 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300"
>
+{commitsAhead}{' '}
{t('git.status.commits', { count: commitsAhead })}{' '}
{t('git.status.ahead')}
</span>
);
}
if (commitsBehind > 0) {
chips.push(
<span
key="behind"
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-amber-100/60 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300"
>
{commitsBehind}{' '}
{t('git.status.commits', { count: commitsBehind })}{' '}
{t('git.status.behind')}
</span>
);
}
if (chips.length > 0)
return <div className="flex items-center gap-2">{chips}</div>;
return (
<span className="text-muted-foreground hidden sm:inline">
{t('git.status.upToDate')}
</span>
);
})()}
</div>
{/* Right: Actions (compact, right-aligned) */}
{branchStatus && (
<div className="shrink-0 flex flex-wrap items-center gap-2 overflow-y-hidden overflow-x-visible max-h-8">
<Button
onClick={handleMergeClick}
disabled={
mergeInfo.hasOpenPR ||
merging ||
hasConflictsCalculated ||
isAttemptRunning ||
((branchStatus.commits_ahead ?? 0) === 0 &&
!pushSuccess &&
!mergeSuccess)
}
variant="outline"
size="xs"
className="border-success text-success hover:bg-success gap-1 shrink-0"
aria-label={mergeButtonLabel}
>
<GitBranchIcon className="h-3.5 w-3.5" />
<span className="truncate max-w-[10ch]">{mergeButtonLabel}</span>
</Button>
<Button
onClick={handlePRButtonClick}
disabled={
pushing ||
isAttemptRunning ||
hasConflictsCalculated ||
(mergeInfo.hasOpenPR &&
branchStatus.remote_commits_ahead === 0) ||
((branchStatus.commits_ahead ?? 0) === 0 &&
(branchStatus.remote_commits_ahead ?? 0) === 0 &&
!pushSuccess &&
!mergeSuccess)
}
variant="outline"
size="xs"
className="border-info text-info hover:bg-info gap-1 shrink-0"
aria-label={prButtonLabel}
>
<GitPullRequest className="h-3.5 w-3.5" />
<span className="truncate max-w-[10ch]">{prButtonLabel}</span>
</Button>
<Button
onClick={handleRebaseDialogOpen}
disabled={rebasing || isAttemptRunning || hasConflictsCalculated}
variant="outline"
size="xs"
className="border-warning text-warning hover:bg-warning gap-1 shrink-0"
aria-label={rebaseButtonLabel}
>
<RefreshCw
className={`h-3.5 w-3.5 ${rebasing ? 'animate-spin' : ''}`}
/>
<span className="truncate max-w-[10ch]">{rebaseButtonLabel}</span>
</Button>
</div>
)}
</div>
</div>
);
}
export default GitOperations;