mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-24 00:13:07 +00:00
Fix concurrency limits and remote branch fetching issues (#788)
* Changes from fix/bug-fixes * feat: Refactor worktree iteration and improve error logging across services * feat: Extract URL/port patterns to module level and fix abort condition * fix: Improve IPv6 loopback handling, select component layout, and terminal UI * feat: Add thinking level defaults and adjust list row padding * Update apps/ui/src/store/app-store.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * feat: Add worktree-aware terminal creation and split options, fix npm security issues from audit * feat: Add tracked remote detection to pull dialog flow * feat: Add merge state tracking to git operations * feat: Improve merge detection and add post-merge action preferences * Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update apps/ui/src/components/views/board-view/dialogs/git-pull-dialog.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix: Pass merge detection info to stash reapplication and handle merge state consistently * fix: Call onPulled callback in merge handlers and add validation checks --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -493,7 +493,7 @@ export function CherryPickDialog({
|
||||
if (step === 'select-commits') {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Cherry className="w-5 h-5 text-foreground" />
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
GitCommit,
|
||||
GitMerge,
|
||||
Sparkles,
|
||||
FilePlus,
|
||||
FileX,
|
||||
@@ -36,7 +37,7 @@ import { toast } from 'sonner';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
|
||||
import type { FileStatus } from '@/types/electron';
|
||||
import type { FileStatus, MergeStateInfo } from '@/types/electron';
|
||||
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
|
||||
|
||||
interface RemoteInfo {
|
||||
@@ -116,6 +117,27 @@ const getStatusBadgeColor = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getMergeTypeLabel = (mergeType?: string) => {
|
||||
switch (mergeType) {
|
||||
case 'both-modified':
|
||||
return 'Both Modified';
|
||||
case 'added-by-us':
|
||||
return 'Added by Us';
|
||||
case 'added-by-them':
|
||||
return 'Added by Them';
|
||||
case 'deleted-by-us':
|
||||
return 'Deleted by Us';
|
||||
case 'deleted-by-them':
|
||||
return 'Deleted by Them';
|
||||
case 'both-added':
|
||||
return 'Both Added';
|
||||
case 'both-deleted':
|
||||
return 'Both Deleted';
|
||||
default:
|
||||
return 'Merge';
|
||||
}
|
||||
};
|
||||
|
||||
function DiffLine({
|
||||
type,
|
||||
content,
|
||||
@@ -190,6 +212,7 @@ export function CommitWorktreeDialog({
|
||||
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
|
||||
const [expandedFile, setExpandedFile] = useState<string | null>(null);
|
||||
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
|
||||
const [mergeState, setMergeState] = useState<MergeStateInfo | undefined>(undefined);
|
||||
|
||||
// Push after commit state
|
||||
const [pushAfterCommit, setPushAfterCommit] = useState(false);
|
||||
@@ -274,6 +297,7 @@ export function CommitWorktreeDialog({
|
||||
setDiffContent('');
|
||||
setSelectedFiles(new Set());
|
||||
setExpandedFile(null);
|
||||
setMergeState(undefined);
|
||||
// Reset push state
|
||||
setPushAfterCommit(false);
|
||||
setRemotes([]);
|
||||
@@ -292,8 +316,20 @@ export function CommitWorktreeDialog({
|
||||
const result = await api.git.getDiffs(worktree.path);
|
||||
if (result.success) {
|
||||
const fileList = result.files ?? [];
|
||||
// Sort merge-affected files first when a merge is in progress
|
||||
if (result.mergeState?.isMerging) {
|
||||
const mergeSet = new Set(result.mergeState.mergeAffectedFiles);
|
||||
fileList.sort((a, b) => {
|
||||
const aIsMerge = mergeSet.has(a.path) || (a.isMergeAffected ?? false);
|
||||
const bIsMerge = mergeSet.has(b.path) || (b.isMergeAffected ?? false);
|
||||
if (aIsMerge && !bIsMerge) return -1;
|
||||
if (!aIsMerge && bIsMerge) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
if (!cancelled) setFiles(fileList);
|
||||
if (!cancelled) setDiffContent(result.diff ?? '');
|
||||
if (!cancelled) setMergeState(result.mergeState);
|
||||
// If any files are already staged, pre-select only staged files
|
||||
// Otherwise select all files by default
|
||||
const stagedFiles = fileList.filter((f) => {
|
||||
@@ -579,6 +615,34 @@ export function CommitWorktreeDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
|
||||
{/* Merge state banner */}
|
||||
{mergeState?.isMerging && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-purple-500/10 border border-purple-500/20">
|
||||
<GitMerge className="w-4 h-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-purple-400">
|
||||
{mergeState.mergeOperationType === 'cherry-pick'
|
||||
? 'Cherry-pick'
|
||||
: mergeState.mergeOperationType === 'rebase'
|
||||
? 'Rebase'
|
||||
: 'Merge'}{' '}
|
||||
in progress
|
||||
</span>
|
||||
{mergeState.conflictFiles.length > 0 ? (
|
||||
<span className="text-purple-400/80 ml-1">
|
||||
— {mergeState.conflictFiles.length} file
|
||||
{mergeState.conflictFiles.length !== 1 ? 's' : ''} with conflicts
|
||||
</span>
|
||||
) : mergeState.isCleanMerge ? (
|
||||
<span className="text-purple-400/80 ml-1">
|
||||
— Clean merge, {mergeState.mergeAffectedFiles.length} file
|
||||
{mergeState.mergeAffectedFiles.length !== 1 ? 's' : ''} affected
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Selection */}
|
||||
<div className="flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -625,13 +689,25 @@ export function CommitWorktreeDialog({
|
||||
const isStaged = idx !== ' ' && idx !== '?';
|
||||
const isUnstaged = wt !== ' ' && wt !== '?';
|
||||
const isUntracked = idx === '?' && wt === '?';
|
||||
const isMergeFile =
|
||||
file.isMergeAffected ||
|
||||
(mergeState?.mergeAffectedFiles?.includes(file.path) ?? false);
|
||||
|
||||
return (
|
||||
<div key={file.path} className="border-b border-border last:border-b-0">
|
||||
<div
|
||||
key={file.path}
|
||||
className={cn(
|
||||
'border-b last:border-b-0',
|
||||
isMergeFile ? 'border-purple-500/30' : 'border-border'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
|
||||
isExpanded && 'bg-accent/30'
|
||||
'flex items-center gap-2 px-3 py-1.5 transition-colors group',
|
||||
isMergeFile
|
||||
? 'bg-purple-500/5 hover:bg-purple-500/10'
|
||||
: 'hover:bg-accent/50',
|
||||
isExpanded && (isMergeFile ? 'bg-purple-500/10' : 'bg-accent/30')
|
||||
)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
@@ -651,11 +727,21 @@ export function CommitWorktreeDialog({
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
{getFileIcon(file.status)}
|
||||
{isMergeFile ? (
|
||||
<GitMerge className="w-3.5 h-3.5 text-purple-500 flex-shrink-0" />
|
||||
) : (
|
||||
getFileIcon(file.status)
|
||||
)}
|
||||
<TruncatedFilePath
|
||||
path={file.path}
|
||||
className="text-xs font-mono flex-1 text-foreground"
|
||||
/>
|
||||
{isMergeFile && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0 bg-purple-500/15 text-purple-400 border-purple-500/30 inline-flex items-center gap-0.5">
|
||||
<GitMerge className="w-2.5 h-2.5" />
|
||||
{getMergeTypeLabel(file.mergeType)}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
|
||||
@@ -810,11 +896,16 @@ export function CommitWorktreeDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{remotes.map((remote) => (
|
||||
<SelectItem key={remote.name} value={remote.name}>
|
||||
<SelectItem
|
||||
key={remote.name}
|
||||
value={remote.name}
|
||||
description={
|
||||
<span className="text-xs text-muted-foreground truncate w-full block">
|
||||
{remote.url}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span className="font-medium">{remote.name}</span>
|
||||
<span className="ml-2 text-muted-foreground text-xs inline-block truncate max-w-[200px] align-bottom">
|
||||
{remote.url}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,11 +17,17 @@ import {
|
||||
FileWarning,
|
||||
Wrench,
|
||||
Sparkles,
|
||||
GitMerge,
|
||||
GitCommitHorizontal,
|
||||
FileText,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { getElectronAPI } from '@/lib/electron';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAppStore } from '@/store/app-store';
|
||||
import type { MergeConflictInfo } from '../worktree-panel/types';
|
||||
|
||||
interface WorktreeInfo {
|
||||
@@ -37,6 +43,7 @@ type PullPhase =
|
||||
| 'local-changes' // Local changes detected, asking user what to do
|
||||
| 'pulling' // Actively pulling (with or without stash)
|
||||
| 'success' // Pull completed successfully
|
||||
| 'merge-complete' // Pull resulted in a merge (not fast-forward, no conflicts)
|
||||
| 'conflict' // Merge conflicts detected
|
||||
| 'error'; // Something went wrong
|
||||
|
||||
@@ -53,6 +60,9 @@ interface PullResult {
|
||||
stashed?: boolean;
|
||||
stashRestored?: boolean;
|
||||
stashRecoveryFailed?: boolean;
|
||||
isMerge?: boolean;
|
||||
isFastForward?: boolean;
|
||||
mergeAffectedFiles?: string[];
|
||||
}
|
||||
|
||||
interface GitPullDialogProps {
|
||||
@@ -62,6 +72,8 @@ interface GitPullDialogProps {
|
||||
remote?: string;
|
||||
onPulled?: () => void;
|
||||
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
|
||||
/** Called when user chooses to commit the merge — opens the commit dialog */
|
||||
onCommitMerge?: (worktree: { path: string; branch: string; isMain: boolean }) => void;
|
||||
}
|
||||
|
||||
export function GitPullDialog({
|
||||
@@ -71,10 +83,54 @@ export function GitPullDialog({
|
||||
remote,
|
||||
onPulled,
|
||||
onCreateConflictResolutionFeature,
|
||||
onCommitMerge,
|
||||
}: GitPullDialogProps) {
|
||||
const [phase, setPhase] = useState<PullPhase>('checking');
|
||||
const [pullResult, setPullResult] = useState<PullResult | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [rememberChoice, setRememberChoice] = useState(false);
|
||||
const [showMergeFiles, setShowMergeFiles] = useState(false);
|
||||
|
||||
const mergePostAction = useAppStore((s) => s.mergePostAction);
|
||||
const setMergePostAction = useAppStore((s) => s.setMergePostAction);
|
||||
|
||||
/**
|
||||
* Determine the appropriate phase after a successful pull.
|
||||
* If the pull resulted in a merge (not fast-forward) and no conflicts,
|
||||
* check user preference before deciding whether to show merge prompt.
|
||||
*/
|
||||
const handleSuccessfulPull = useCallback(
|
||||
(result: PullResult) => {
|
||||
setPullResult(result);
|
||||
|
||||
if (result.isMerge && !result.hasConflicts) {
|
||||
// Merge happened — check user preference
|
||||
if (mergePostAction === 'commit') {
|
||||
// User preference: auto-commit
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
// Auto-trigger commit dialog
|
||||
if (worktree && onCommitMerge) {
|
||||
onCommitMerge(worktree);
|
||||
onOpenChange(false);
|
||||
}
|
||||
} else if (mergePostAction === 'manual') {
|
||||
// User preference: manual review
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
} else {
|
||||
// No preference — show merge prompt; onPulled will be called from the
|
||||
// user-action handlers (handleCommitMerge / handleMergeManually) once
|
||||
// the user makes their choice, consistent with the conflict phase.
|
||||
setPhase('merge-complete');
|
||||
}
|
||||
} else {
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
}
|
||||
},
|
||||
[mergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]
|
||||
);
|
||||
|
||||
const checkForLocalChanges = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
@@ -103,9 +159,7 @@ export function GitPullDialog({
|
||||
setPhase('local-changes');
|
||||
} else if (result.result?.pulled !== undefined) {
|
||||
// No local changes, pull went through (or already up to date)
|
||||
setPullResult(result.result);
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
handleSuccessfulPull(result.result);
|
||||
} else {
|
||||
// Unexpected response: success but no recognizable fields
|
||||
setPullResult(result.result ?? null);
|
||||
@@ -116,18 +170,33 @@ export function GitPullDialog({
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to check for changes');
|
||||
setPhase('error');
|
||||
}
|
||||
}, [worktree, remote, onPulled]);
|
||||
}, [worktree, remote, handleSuccessfulPull]);
|
||||
|
||||
// Reset state when dialog opens
|
||||
// Keep a ref to the latest checkForLocalChanges to break the circular dependency
|
||||
// between the "reset/start" effect and the callback chain. Without this, any
|
||||
// change in onPulled (passed from the parent) would recreate handleSuccessfulPull
|
||||
// → checkForLocalChanges → re-trigger the effect while the dialog is already open,
|
||||
// causing the pull flow to restart unintentionally.
|
||||
const checkForLocalChangesRef = useRef(checkForLocalChanges);
|
||||
useEffect(() => {
|
||||
checkForLocalChangesRef.current = checkForLocalChanges;
|
||||
});
|
||||
|
||||
// Reset state when dialog opens and start the initial pull check.
|
||||
// Depends only on `open` and `worktree` — NOT on `checkForLocalChanges` —
|
||||
// so that parent callback re-creations don't restart the pull flow mid-flight.
|
||||
useEffect(() => {
|
||||
if (open && worktree) {
|
||||
setPhase('checking');
|
||||
setPullResult(null);
|
||||
setErrorMessage(null);
|
||||
// Start the initial check
|
||||
checkForLocalChanges();
|
||||
setRememberChoice(false);
|
||||
setShowMergeFiles(false);
|
||||
// Start the initial check using the ref so we always call the latest version
|
||||
// without making it a dependency of this effect.
|
||||
checkForLocalChangesRef.current();
|
||||
}
|
||||
}, [open, worktree, checkForLocalChanges]);
|
||||
}, [open, worktree]);
|
||||
|
||||
const handlePullWithStash = useCallback(async () => {
|
||||
if (!worktree) return;
|
||||
@@ -155,8 +224,7 @@ export function GitPullDialog({
|
||||
if (result.result?.hasConflicts) {
|
||||
setPhase('conflict');
|
||||
} else if (result.result?.pulled) {
|
||||
setPhase('success');
|
||||
onPulled?.();
|
||||
handleSuccessfulPull(result.result);
|
||||
} else {
|
||||
// Unrecognized response: no pulled flag and no conflicts
|
||||
console.warn('handlePullWithStash: unrecognized response', result.result);
|
||||
@@ -167,7 +235,7 @@ export function GitPullDialog({
|
||||
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
|
||||
setPhase('error');
|
||||
}
|
||||
}, [worktree, remote, onPulled]);
|
||||
}, [worktree, remote, handleSuccessfulPull]);
|
||||
|
||||
const handleResolveWithAI = useCallback(() => {
|
||||
if (!worktree || !pullResult || !onCreateConflictResolutionFeature) return;
|
||||
@@ -186,6 +254,35 @@ export function GitPullDialog({
|
||||
onOpenChange(false);
|
||||
}, [worktree, pullResult, remote, onCreateConflictResolutionFeature, onOpenChange]);
|
||||
|
||||
const handleCommitMerge = useCallback(() => {
|
||||
if (!worktree || !onCommitMerge) {
|
||||
// No handler available — show feedback and bail without persisting preference
|
||||
toast.error('Commit merge is not available', {
|
||||
description: 'The commit merge action is not configured for this context.',
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (rememberChoice) {
|
||||
setMergePostAction('commit');
|
||||
}
|
||||
onPulled?.();
|
||||
onCommitMerge(worktree);
|
||||
onOpenChange(false);
|
||||
}, [rememberChoice, setMergePostAction, worktree, onCommitMerge, onPulled, onOpenChange]);
|
||||
|
||||
const handleMergeManually = useCallback(() => {
|
||||
if (rememberChoice) {
|
||||
setMergePostAction('manual');
|
||||
}
|
||||
toast.info('Merge left for manual review', {
|
||||
description: 'Review the merged files and commit when ready.',
|
||||
duration: 5000,
|
||||
});
|
||||
onPulled?.();
|
||||
onOpenChange(false);
|
||||
}, [rememberChoice, setMergePostAction, onPulled, onOpenChange]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
@@ -336,6 +433,137 @@ export function GitPullDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Merge Complete Phase — post-merge prompt */}
|
||||
{phase === 'merge-complete' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-purple-500" />
|
||||
Merge Complete
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<span className="block">
|
||||
Pull resulted in a merge on{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
|
||||
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
|
||||
<span>
|
||||
{' '}
|
||||
affecting {pullResult.mergeAffectedFiles.length} file
|
||||
{pullResult.mergeAffectedFiles.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
. How would you like to proceed?
|
||||
</span>
|
||||
|
||||
{pullResult?.mergeAffectedFiles && pullResult.mergeAffectedFiles.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowMergeFiles(!showMergeFiles)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{showMergeFiles ? 'Hide' : 'Show'} affected files (
|
||||
{pullResult.mergeAffectedFiles.length})
|
||||
</button>
|
||||
{showMergeFiles && (
|
||||
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
|
||||
{pullResult.mergeAffectedFiles.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
|
||||
>
|
||||
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
|
||||
<span className="truncate">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pullResult?.stashed &&
|
||||
pullResult?.stashRestored &&
|
||||
!pullResult?.stashRecoveryFailed && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-md bg-green-500/10 border border-green-500/20">
|
||||
<Archive className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||
<span className="text-green-600 dark:text-green-400 text-sm">
|
||||
Your stashed changes have been restored successfully.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground font-medium mb-2">
|
||||
Choose how to proceed:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<strong>Commit Merge</strong> — Open the commit dialog with a merge
|
||||
commit message
|
||||
</li>
|
||||
<li>
|
||||
<strong>Review Manually</strong> — Leave the working tree as-is for
|
||||
manual review
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Remember choice option */}
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
checked={rememberChoice}
|
||||
onCheckedChange={(checked) => setRememberChoice(checked === true)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<Settings className="w-3 h-3" />
|
||||
Remember my choice for future merges
|
||||
</label>
|
||||
{(rememberChoice || mergePostAction) && (
|
||||
<span className="text-xs text-muted-foreground ml-auto flex items-center gap-2">
|
||||
<span className="opacity-70">
|
||||
Current:{' '}
|
||||
{mergePostAction === 'commit'
|
||||
? 'auto-commit'
|
||||
: mergePostAction === 'manual'
|
||||
? 'manual review'
|
||||
: 'ask every time'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMergePostAction(null);
|
||||
setRememberChoice(false);
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Reset preference
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
|
||||
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Review Manually
|
||||
</Button>
|
||||
{worktree && onCommitMerge && (
|
||||
<Button
|
||||
onClick={handleCommitMerge}
|
||||
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<GitCommitHorizontal className="w-4 h-4 mr-2" />
|
||||
Commit Merge
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Conflict Phase */}
|
||||
{phase === 'conflict' && (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* Post-Merge Prompt Dialog
|
||||
*
|
||||
* Shown after a pull or stash apply results in a clean merge (no conflicts).
|
||||
* Presents the user with two options:
|
||||
* 1. Commit the merge — automatically stage all merge-result files and open commit dialog
|
||||
* 2. Merge manually — leave the working tree as-is for manual review
|
||||
*
|
||||
* The user's choice can be persisted as a preference to avoid repeated prompts.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { GitMerge, GitCommitHorizontal, FileText, Settings } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type MergePostAction = 'commit' | 'manual' | null;
|
||||
|
||||
interface PostMergePromptDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Branch name where the merge happened */
|
||||
branchName: string;
|
||||
/** Number of files affected by the merge */
|
||||
mergeFileCount: number;
|
||||
/** List of files affected by the merge */
|
||||
mergeAffectedFiles?: string[];
|
||||
/** Called when the user chooses to commit the merge */
|
||||
onCommitMerge: () => void;
|
||||
/** Called when the user chooses to handle the merge manually */
|
||||
onMergeManually: () => void;
|
||||
/** Current saved preference (null = ask every time) */
|
||||
savedPreference?: MergePostAction;
|
||||
/** Called when the user changes the preference */
|
||||
onSavePreference?: (preference: MergePostAction) => void;
|
||||
}
|
||||
|
||||
export function PostMergePromptDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
branchName,
|
||||
mergeFileCount,
|
||||
mergeAffectedFiles,
|
||||
onCommitMerge,
|
||||
onMergeManually,
|
||||
savedPreference,
|
||||
onSavePreference,
|
||||
}: PostMergePromptDialogProps) {
|
||||
const [rememberChoice, setRememberChoice] = useState(false);
|
||||
const [showFiles, setShowFiles] = useState(false);
|
||||
|
||||
// Reset transient state each time the dialog is opened
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setRememberChoice(false);
|
||||
setShowFiles(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCommitMerge = useCallback(() => {
|
||||
if (rememberChoice && onSavePreference) {
|
||||
onSavePreference('commit');
|
||||
}
|
||||
onCommitMerge();
|
||||
onOpenChange(false);
|
||||
}, [rememberChoice, onSavePreference, onCommitMerge, onOpenChange]);
|
||||
|
||||
const handleMergeManually = useCallback(() => {
|
||||
if (rememberChoice && onSavePreference) {
|
||||
onSavePreference('manual');
|
||||
}
|
||||
onMergeManually();
|
||||
onOpenChange(false);
|
||||
}, [rememberChoice, onSavePreference, onMergeManually, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[520px] w-full max-w-full sm:rounded-xl rounded-none dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitMerge className="w-5 h-5 text-purple-500" />
|
||||
Merge Complete
|
||||
</DialogTitle>
|
||||
<DialogDescription asChild>
|
||||
<div className="space-y-3">
|
||||
<span className="block">
|
||||
A merge was successfully completed on{' '}
|
||||
<code className="font-mono bg-muted px-1 rounded">{branchName}</code>
|
||||
{mergeFileCount > 0 && (
|
||||
<span>
|
||||
{' '}
|
||||
affecting {mergeFileCount} file{mergeFileCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
. How would you like to proceed?
|
||||
</span>
|
||||
|
||||
{mergeAffectedFiles && mergeAffectedFiles.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowFiles(!showFiles)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
{showFiles ? 'Hide' : 'Show'} affected files ({mergeAffectedFiles.length})
|
||||
</button>
|
||||
{showFiles && (
|
||||
<div className="mt-1.5 border border-border rounded-lg overflow-hidden max-h-[150px] overflow-y-auto scrollbar-visible">
|
||||
{mergeAffectedFiles.map((file) => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs font-mono border-b border-border last:border-b-0 hover:bg-accent/30"
|
||||
>
|
||||
<GitMerge className="w-3 h-3 text-purple-500 flex-shrink-0" />
|
||||
<span className="truncate">{file}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
|
||||
<p className="text-sm text-muted-foreground font-medium mb-2">
|
||||
Choose how to proceed:
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
|
||||
<li>
|
||||
<strong>Commit Merge</strong> — Stage all merge files and open the commit
|
||||
dialog with a pre-populated merge commit message
|
||||
</li>
|
||||
<li>
|
||||
<strong>Review Manually</strong> — Leave the working tree as-is so you can
|
||||
review changes and commit at your own pace
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Remember choice option */}
|
||||
{onSavePreference && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
|
||||
<Checkbox
|
||||
checked={rememberChoice}
|
||||
onCheckedChange={(checked) => setRememberChoice(checked)}
|
||||
className="rounded border-border"
|
||||
/>
|
||||
<Settings className="w-3 h-3" />
|
||||
Remember my choice for future merges
|
||||
</label>
|
||||
{savedPreference && (
|
||||
<button
|
||||
onClick={() => onSavePreference(null)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors ml-auto"
|
||||
>
|
||||
Reset preference
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className={cn('flex-col sm:flex-row gap-2')}>
|
||||
<Button variant="outline" onClick={handleMergeManually} className="w-full sm:w-auto">
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
Review Manually
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCommitMerge}
|
||||
className="w-full sm:w-auto bg-purple-600 hover:bg-purple-700 text-white"
|
||||
>
|
||||
<GitCommitHorizontal className="w-4 h-4 mr-2" />
|
||||
Commit Merge
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<GitCommit className="w-5 h-5" />
|
||||
@@ -263,7 +263,7 @@ export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsD
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="flex-1 min-h-0 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="h-full px-6 pb-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
|
||||
@@ -367,7 +367,7 @@ export function ViewStashesDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Archive className="w-5 h-5" />
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ViewWorktreeChangesDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[900px] sm:max-h-[85dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col dialog-fullscreen-mobile">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" />
|
||||
@@ -54,7 +54,7 @@ export function ViewWorktreeChangesDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 sm:min-h-[600px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-visible -mx-6 -mb-6">
|
||||
<div className="h-full px-6 pb-6">
|
||||
<GitDiffPanel
|
||||
projectPath={projectPath}
|
||||
|
||||
Reference in New Issue
Block a user