mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-20 11:03:08 +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:
@@ -17,6 +17,8 @@ import {
|
||||
interface CardActionsProps {
|
||||
feature: Feature;
|
||||
isCurrentAutoTask: boolean;
|
||||
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
|
||||
isRunningTask?: boolean;
|
||||
hasContext?: boolean;
|
||||
shortcutKey?: string;
|
||||
isSelectionMode?: boolean;
|
||||
@@ -36,6 +38,7 @@ interface CardActionsProps {
|
||||
export const CardActions = memo(function CardActions({
|
||||
feature,
|
||||
isCurrentAutoTask,
|
||||
isRunningTask = false,
|
||||
hasContext: _hasContext,
|
||||
shortcutKey,
|
||||
isSelectionMode = false,
|
||||
@@ -340,7 +343,57 @@ export const CardActions = memo(function CardActions({
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{/* Running task with stale status: feature is tracked as running but status hasn't updated yet.
|
||||
Show Logs/Stop controls instead of Make to avoid confusing UI. */}
|
||||
{!isCurrentAutoTask &&
|
||||
isRunningTask &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'interrupted' ||
|
||||
feature.status === 'ready') && (
|
||||
<>
|
||||
{onViewOutput && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 h-7 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewOutput();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`view-output-${feature.id}`}
|
||||
>
|
||||
<FileText className="w-3 h-3 mr-1 shrink-0" />
|
||||
<span className="truncate">Logs</span>
|
||||
{shortcutKey && (
|
||||
<span
|
||||
className="ml-1.5 px-1 py-0.5 text-[9px] font-mono rounded bg-foreground/10"
|
||||
data-testid={`shortcut-key-${feature.id}`}
|
||||
>
|
||||
{shortcutKey}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{onForceStop && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-[11px] px-2 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onForceStop();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
data-testid={`force-stop-${feature.id}`}
|
||||
>
|
||||
<StopCircle className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!isCurrentAutoTask &&
|
||||
!isRunningTask &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'interrupted' ||
|
||||
feature.status === 'ready') && (
|
||||
|
||||
@@ -114,15 +114,27 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
currentProject: state.currentProject,
|
||||
}))
|
||||
);
|
||||
// A card should only display as "actively running" if it's both in the
|
||||
// runningAutoTasks list AND in an execution-compatible status. Cards in resting
|
||||
// states (backlog, ready, waiting_approval, verified, completed) should never
|
||||
// show running controls, even if they appear in runningAutoTasks due to stale
|
||||
// state (e.g., after a server restart that reconciled features back to backlog).
|
||||
// A card should display as "actively running" if it's in the runningAutoTasks list
|
||||
// AND in an execution-compatible status. However, there's a race window where a feature
|
||||
// is tracked as running (in runningAutoTasks) but its disk/UI status hasn't caught up yet
|
||||
// (still 'backlog', 'ready', or 'interrupted'). In this case, we still want to show
|
||||
// running controls (Logs/Stop) and animated border, but not the full "actively running"
|
||||
// state that gates all UI behavior.
|
||||
const isInExecutionState =
|
||||
feature.status === 'in_progress' ||
|
||||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
|
||||
const isActivelyRunning = !!isCurrentAutoTask && isInExecutionState;
|
||||
// isRunningWithStaleStatus: feature is tracked as running but status hasn't updated yet.
|
||||
// This happens during the timing gap between when the server starts a feature and when
|
||||
// the UI receives the status update. Show running UI to prevent "Make" button flash.
|
||||
const isRunningWithStaleStatus =
|
||||
!!isCurrentAutoTask &&
|
||||
!isInExecutionState &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'interrupted');
|
||||
// Show running visual treatment for both fully confirmed and stale-status running tasks
|
||||
const showRunningVisuals = isActivelyRunning || isRunningWithStaleStatus;
|
||||
const [isLifted, setIsLifted] = useState(false);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -135,6 +147,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
const isDraggable =
|
||||
!isSelectionMode &&
|
||||
!isRunningWithStaleStatus &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'interrupted' ||
|
||||
feature.status === 'ready' ||
|
||||
@@ -198,13 +211,13 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
'kanban-card-content h-full relative',
|
||||
reduceEffects ? 'shadow-none' : 'shadow-sm',
|
||||
'transition-all duration-200 ease-out',
|
||||
// Disable hover translate for in-progress cards to prevent gap showing gradient
|
||||
// Disable hover translate for running cards to prevent gap showing gradient
|
||||
isInteractive &&
|
||||
!reduceEffects &&
|
||||
!isActivelyRunning &&
|
||||
!showRunningVisuals &&
|
||||
'hover:-translate-y-0.5 hover:shadow-md hover:shadow-black/10 bg-transparent',
|
||||
!glassmorphism && 'backdrop-blur-[0px]!',
|
||||
!isActivelyRunning &&
|
||||
!showRunningVisuals &&
|
||||
cardBorderEnabled &&
|
||||
(cardBorderOpacity === 100 ? 'border-border/50' : 'border'),
|
||||
hasError && 'border-[var(--status-error)] border-2 shadow-[var(--status-error-bg)] shadow-lg',
|
||||
@@ -221,7 +234,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
|
||||
const renderCardContent = () => (
|
||||
<Card
|
||||
style={isActivelyRunning ? undefined : cardStyle}
|
||||
style={showRunningVisuals ? undefined : cardStyle}
|
||||
className={innerCardClasses}
|
||||
onDoubleClick={isSelectionMode ? undefined : onEdit}
|
||||
onClick={handleCardClick}
|
||||
@@ -290,6 +303,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
<CardActions
|
||||
feature={feature}
|
||||
isCurrentAutoTask={isActivelyRunning}
|
||||
isRunningTask={!!isCurrentAutoTask}
|
||||
hasContext={hasContext}
|
||||
shortcutKey={shortcutKey}
|
||||
isSelectionMode={isSelectionMode}
|
||||
@@ -316,7 +330,7 @@ export const KanbanCard = memo(function KanbanCard({
|
||||
className={wrapperClasses}
|
||||
data-testid={`kanban-card-${feature.id}`}
|
||||
>
|
||||
{isActivelyRunning ? (
|
||||
{showRunningVisuals ? (
|
||||
<div className="animated-border-wrapper">{renderCardContent()}</div>
|
||||
) : (
|
||||
renderCardContent()
|
||||
|
||||
@@ -42,7 +42,12 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
contentStyle,
|
||||
disableItemSpacing = false,
|
||||
}: KanbanColumnProps) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id });
|
||||
const { setNodeRef, isOver: isColumnOver } = useDroppable({ id });
|
||||
// Also make the header explicitly a drop target so dragging to the top of the column works
|
||||
const { setNodeRef: setHeaderDropRef, isOver: isHeaderOver } = useDroppable({
|
||||
id: `column-header-${id}`,
|
||||
});
|
||||
const isOver = isColumnOver || isHeaderOver;
|
||||
|
||||
// Use inline style for width if provided, otherwise use default w-72
|
||||
const widthStyle = width ? { width: `${width}px`, flexShrink: 0 } : undefined;
|
||||
@@ -70,8 +75,9 @@ export const KanbanColumn = memo(function KanbanColumn({
|
||||
style={{ opacity: opacity / 100 }}
|
||||
/>
|
||||
|
||||
{/* Column Header */}
|
||||
{/* Column Header - also registered as a drop target so dragging to the header area works */}
|
||||
<div
|
||||
ref={setHeaderDropRef}
|
||||
className={cn(
|
||||
'relative z-10 flex items-center gap-3 px-3 py-2.5',
|
||||
showBorder && 'border-b border-border/40'
|
||||
|
||||
@@ -209,15 +209,22 @@ export const ListRow = memo(function ListRow({
|
||||
blockingDependencies = [],
|
||||
className,
|
||||
}: ListRowProps) {
|
||||
// A row should only display as "actively running" if it's both in the
|
||||
// runningAutoTasks list AND in an execution-compatible status. Features in resting
|
||||
// states (backlog, ready, waiting_approval, verified, completed) should never
|
||||
// show running controls, even if they appear in runningAutoTasks due to stale
|
||||
// state (e.g., after a server restart that reconciled features back to backlog).
|
||||
// A row should display as "actively running" if it's in the runningAutoTasks list
|
||||
// AND in an execution-compatible status. However, there's a race window where a feature
|
||||
// is tracked as running but its status hasn't caught up yet (still 'backlog', 'ready',
|
||||
// or 'interrupted'). We handle this with isRunningWithStaleStatus.
|
||||
const isInExecutionState =
|
||||
feature.status === 'in_progress' ||
|
||||
(typeof feature.status === 'string' && feature.status.startsWith('pipeline_'));
|
||||
const isActivelyRunning = isCurrentAutoTask && isInExecutionState;
|
||||
// Feature is tracked as running but status hasn't updated yet - show running UI
|
||||
const isRunningWithStaleStatus =
|
||||
isCurrentAutoTask &&
|
||||
!isInExecutionState &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'interrupted');
|
||||
const showRunningVisuals = isActivelyRunning || isRunningWithStaleStatus;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -268,7 +275,7 @@ export const ListRow = memo(function ListRow({
|
||||
>
|
||||
{/* Checkbox column */}
|
||||
{showCheckbox && (
|
||||
<div role="cell" className="flex items-center justify-center w-10 px-2 py-3 shrink-0">
|
||||
<div role="cell" className="flex items-center justify-center w-10 px-2 py-2 shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
@@ -287,7 +294,7 @@ export const ListRow = memo(function ListRow({
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'flex items-center pl-3 pr-0 py-3 gap-0',
|
||||
'flex items-center pl-3 pr-0 py-2 gap-0',
|
||||
getColumnWidth('title'),
|
||||
getColumnAlign('title')
|
||||
)}
|
||||
@@ -296,7 +303,7 @@ export const ListRow = memo(function ListRow({
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'font-medium truncate',
|
||||
'text-sm font-medium truncate',
|
||||
feature.titleGenerating && !feature.title && 'animate-pulse text-muted-foreground'
|
||||
)}
|
||||
title={feature.title || feature.description}
|
||||
@@ -325,7 +332,7 @@ export const ListRow = memo(function ListRow({
|
||||
<div
|
||||
role="cell"
|
||||
className={cn(
|
||||
'flex items-center pl-0 pr-3 py-3 shrink-0',
|
||||
'flex items-center pl-0 pr-3 py-2 shrink-0',
|
||||
getColumnWidth('priority'),
|
||||
getColumnAlign('priority')
|
||||
)}
|
||||
@@ -358,14 +365,19 @@ export const ListRow = memo(function ListRow({
|
||||
</div>
|
||||
|
||||
{/* Actions column */}
|
||||
<div role="cell" className="flex items-center justify-end px-3 py-3 w-[80px] shrink-0">
|
||||
<RowActions feature={feature} handlers={handlers} isCurrentAutoTask={isActivelyRunning} />
|
||||
<div role="cell" className="flex items-center justify-end px-3 py-2 w-[80px] shrink-0">
|
||||
<RowActions
|
||||
feature={feature}
|
||||
handlers={handlers}
|
||||
isCurrentAutoTask={isActivelyRunning}
|
||||
isRunningTask={!!isCurrentAutoTask}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Wrap with animated border for currently running auto task
|
||||
if (isActivelyRunning) {
|
||||
// Wrap with animated border for currently running auto task (including stale status)
|
||||
if (showRunningVisuals) {
|
||||
return <div className="animated-border-wrapper-row">{rowContent}</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,8 @@ export interface RowActionsProps {
|
||||
handlers: RowActionHandlers;
|
||||
/** Whether this feature is the current auto task (agent is running) */
|
||||
isCurrentAutoTask?: boolean;
|
||||
/** Whether this feature is tracked as a running task (may be true even before status updates to in_progress) */
|
||||
isRunningTask?: boolean;
|
||||
/** Whether the dropdown menu is open */
|
||||
isOpen?: boolean;
|
||||
/** Callback when the dropdown open state changes */
|
||||
@@ -115,7 +117,8 @@ const MenuItem = memo(function MenuItem({
|
||||
function getPrimaryAction(
|
||||
feature: Feature,
|
||||
handlers: RowActionHandlers,
|
||||
isCurrentAutoTask: boolean
|
||||
isCurrentAutoTask: boolean,
|
||||
isRunningTask: boolean = false
|
||||
): {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
@@ -135,6 +138,24 @@ function getPrimaryAction(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Running task with stale status - show stop instead of Make
|
||||
// This handles the race window where the feature is tracked as running
|
||||
// but status hasn't updated to in_progress yet
|
||||
if (
|
||||
isRunningTask &&
|
||||
(feature.status === 'backlog' ||
|
||||
feature.status === 'ready' ||
|
||||
feature.status === 'interrupted') &&
|
||||
handlers.onForceStop
|
||||
) {
|
||||
return {
|
||||
icon: StopCircle,
|
||||
label: 'Stop',
|
||||
onClick: handlers.onForceStop,
|
||||
variant: 'destructive',
|
||||
};
|
||||
}
|
||||
|
||||
// Backlog - implement is primary
|
||||
if (feature.status === 'backlog' && handlers.onImplement) {
|
||||
return {
|
||||
@@ -263,6 +284,7 @@ export const RowActions = memo(function RowActions({
|
||||
feature,
|
||||
handlers,
|
||||
isCurrentAutoTask = false,
|
||||
isRunningTask = false,
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
className,
|
||||
@@ -286,7 +308,7 @@ export const RowActions = memo(function RowActions({
|
||||
[setOpen]
|
||||
);
|
||||
|
||||
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask);
|
||||
const primaryAction = getPrimaryAction(feature, handlers, isCurrentAutoTask, isRunningTask);
|
||||
const secondaryActions = getSecondaryActions(feature, handlers);
|
||||
|
||||
// Helper to close menu after action
|
||||
@@ -403,7 +425,7 @@ export const RowActions = memo(function RowActions({
|
||||
)}
|
||||
|
||||
{/* Backlog actions */}
|
||||
{!isCurrentAutoTask && feature.status === 'backlog' && (
|
||||
{!isCurrentAutoTask && !isRunningTask && feature.status === 'backlog' && (
|
||||
<>
|
||||
<MenuItem icon={Edit} label="Edit" onClick={withClose(handlers.onEdit)} />
|
||||
{feature.planSpec?.content && handlers.onViewPlan && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -94,8 +94,6 @@ export function useBoardActions({
|
||||
skipVerificationInAutoMode,
|
||||
isPrimaryWorktreeBranch,
|
||||
getPrimaryWorktreeBranch,
|
||||
getAutoModeState,
|
||||
getMaxConcurrencyForWorktree,
|
||||
} = useAppStore();
|
||||
const autoMode = useAutoMode();
|
||||
|
||||
@@ -561,38 +559,9 @@ export function useBoardActions({
|
||||
|
||||
const handleStartImplementation = useCallback(
|
||||
async (feature: Feature) => {
|
||||
// Check capacity for the feature's specific worktree, not the current view
|
||||
// Normalize the branch name: if the feature's branch is the primary worktree branch,
|
||||
// treat it as null (main worktree) to match how running tasks are stored
|
||||
const rawBranchName = feature.branchName ?? null;
|
||||
const featureBranchName =
|
||||
currentProject?.path &&
|
||||
rawBranchName &&
|
||||
isPrimaryWorktreeBranch(currentProject.path, rawBranchName)
|
||||
? null
|
||||
: rawBranchName;
|
||||
const featureWorktreeState = currentProject
|
||||
? getAutoModeState(currentProject.id, featureBranchName)
|
||||
: null;
|
||||
// Use getMaxConcurrencyForWorktree which correctly falls back to global maxConcurrency
|
||||
// instead of autoMode.maxConcurrency which only falls back to DEFAULT_MAX_CONCURRENCY (1)
|
||||
const featureMaxConcurrency = currentProject
|
||||
? getMaxConcurrencyForWorktree(currentProject.id, featureBranchName)
|
||||
: autoMode.maxConcurrency;
|
||||
const featureRunningCount = featureWorktreeState?.runningTasks?.length ?? 0;
|
||||
const canStartInWorktree = featureRunningCount < featureMaxConcurrency;
|
||||
|
||||
if (!canStartInWorktree) {
|
||||
const worktreeDesc = featureBranchName
|
||||
? `worktree "${featureBranchName}"`
|
||||
: 'main worktree';
|
||||
toast.error('Concurrency limit reached', {
|
||||
description: `${worktreeDesc} can only have ${featureMaxConcurrency} task${
|
||||
featureMaxConcurrency > 1 ? 's' : ''
|
||||
} running at a time. Wait for a task to complete or increase the limit.`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// Note: No concurrency limit check here. Manual feature starts should never
|
||||
// be blocked by the auto mode concurrency limit. The concurrency limit only
|
||||
// governs how many features the auto-loop picks up automatically.
|
||||
|
||||
// Check for blocking dependencies and show warning if enabled
|
||||
if (enableDependencyBlocking) {
|
||||
@@ -681,18 +650,7 @@ export function useBoardActions({
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[
|
||||
autoMode,
|
||||
enableDependencyBlocking,
|
||||
features,
|
||||
updateFeature,
|
||||
persistFeatureUpdate,
|
||||
handleRunFeature,
|
||||
currentProject,
|
||||
getAutoModeState,
|
||||
getMaxConcurrencyForWorktree,
|
||||
isPrimaryWorktreeBranch,
|
||||
]
|
||||
[enableDependencyBlocking, features, updateFeature, persistFeatureUpdate, handleRunFeature]
|
||||
);
|
||||
|
||||
const handleVerifyFeature = useCallback(
|
||||
|
||||
@@ -163,13 +163,22 @@ export function useBoardDragDrop({
|
||||
|
||||
let targetStatus: ColumnId | null = null;
|
||||
|
||||
// Normalize the over ID: strip 'column-header-' prefix if the card was dropped
|
||||
// directly onto the column header droppable zone (e.g. 'column-header-backlog' → 'backlog')
|
||||
const effectiveOverId = overId.startsWith('column-header-')
|
||||
? overId.replace('column-header-', '')
|
||||
: overId;
|
||||
|
||||
// Check if we dropped on a column
|
||||
const column = COLUMNS.find((c) => c.id === overId);
|
||||
const column = COLUMNS.find((c) => c.id === effectiveOverId);
|
||||
if (column) {
|
||||
targetStatus = column.id;
|
||||
} else if (effectiveOverId.startsWith('pipeline_')) {
|
||||
// Pipeline step column (not in static COLUMNS list)
|
||||
targetStatus = effectiveOverId as ColumnId;
|
||||
} else {
|
||||
// Dropped on another feature - find its column
|
||||
const overFeature = features.find((f) => f.id === overId);
|
||||
const overFeature = features.find((f) => f.id === effectiveOverId);
|
||||
if (overFeature) {
|
||||
targetStatus = overFeature.status;
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function DevServerLogsPanel({
|
||||
compact
|
||||
>
|
||||
{/* Compact Header */}
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12">
|
||||
<DialogHeader className="shrink-0 px-4 py-3 border-b border-border/50 pr-12 dialog-compact-header-mobile">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
|
||||
@@ -354,12 +354,19 @@ export function WorktreeActionsDropdown({
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
Dev Server Running (:{devServerInfo?.port})
|
||||
{devServerInfo?.urlDetected === false
|
||||
? 'Dev Server Starting...'
|
||||
: `Dev Server Running (:${devServerInfo?.port})`}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onOpenDevServerUrl(worktree)}
|
||||
className="text-xs"
|
||||
aria-label={`Open dev server on port ${devServerInfo?.port} in browser`}
|
||||
disabled={devServerInfo?.urlDetected === false}
|
||||
aria-label={
|
||||
devServerInfo?.urlDetected === false
|
||||
? 'Open dev server in browser'
|
||||
: `Open dev server on port ${devServerInfo?.port} in browser`
|
||||
}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5 mr-2" aria-hidden="true" />
|
||||
Open in Browser
|
||||
|
||||
@@ -308,7 +308,11 @@ export function WorktreeDropdown({
|
||||
{selectedStatus.devServerRunning && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
||||
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
|
||||
title={
|
||||
selectedStatus.devServerInfo?.urlDetected === false
|
||||
? 'Dev server starting...'
|
||||
: `Dev server running on port ${selectedStatus.devServerInfo?.port}`
|
||||
}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</span>
|
||||
|
||||
@@ -206,6 +206,16 @@ export function useDevServerLogs({ worktreePath, autoSubscribe = true }: UseDevS
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case 'dev-server:url-detected': {
|
||||
const { payload } = event;
|
||||
logger.info('Dev server URL detected:', payload);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
url: payload.url,
|
||||
port: payload.port,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,10 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
if (result.success && result.result?.servers) {
|
||||
const serversMap = new Map<string, DevServerInfo>();
|
||||
for (const server of result.result.servers) {
|
||||
serversMap.set(server.worktreePath, server);
|
||||
serversMap.set(normalizePath(server.worktreePath), {
|
||||
...server,
|
||||
urlDetected: server.urlDetected ?? true,
|
||||
});
|
||||
}
|
||||
setRunningDevServers(serversMap);
|
||||
}
|
||||
@@ -38,6 +41,39 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
fetchDevServers();
|
||||
}, [fetchDevServers]);
|
||||
|
||||
// Subscribe to url-detected events to update port/url when the actual dev server port is detected
|
||||
useEffect(() => {
|
||||
const api = getElectronAPI();
|
||||
if (!api?.worktree?.onDevServerLogEvent) return;
|
||||
|
||||
const unsubscribe = api.worktree.onDevServerLogEvent((event) => {
|
||||
if (event.type === 'dev-server:url-detected') {
|
||||
const { worktreePath, url, port } = event.payload;
|
||||
const key = normalizePath(worktreePath);
|
||||
let didUpdate = false;
|
||||
setRunningDevServers((prev) => {
|
||||
const existing = prev.get(key);
|
||||
if (!existing) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(key, {
|
||||
...existing,
|
||||
url,
|
||||
port,
|
||||
urlDetected: true,
|
||||
});
|
||||
didUpdate = true;
|
||||
return next;
|
||||
});
|
||||
if (didUpdate) {
|
||||
logger.info(`Dev server URL detected for ${worktreePath}: ${url} (port ${port})`);
|
||||
toast.success(`Dev server running on port ${port}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
const getWorktreeKey = useCallback(
|
||||
(worktree: WorktreeInfo) => {
|
||||
const path = worktree.isMain ? projectPath : worktree.path;
|
||||
@@ -68,10 +104,11 @@ export function useDevServers({ projectPath }: UseDevServersOptions) {
|
||||
worktreePath: result.result!.worktreePath,
|
||||
port: result.result!.port,
|
||||
url: result.result!.url,
|
||||
urlDetected: false,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
toast.success(`Dev server started on port ${result.result.port}`);
|
||||
toast.success('Dev server started, detecting port...');
|
||||
} else {
|
||||
toast.error(result.error || 'Failed to start dev server');
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ export interface DevServerInfo {
|
||||
worktreePath: string;
|
||||
port: number;
|
||||
url: string;
|
||||
/** Whether the actual URL/port has been detected from server output */
|
||||
urlDetected?: boolean;
|
||||
}
|
||||
|
||||
export interface TestSessionInfo {
|
||||
|
||||
@@ -646,57 +646,101 @@ export function WorktreePanel({
|
||||
setPushToRemoteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Keep a ref to pullDialogWorktree so handlePullCompleted can access the current
|
||||
// value without including it in the dependency array. If pullDialogWorktree were
|
||||
// a dep of handlePullCompleted, changing it would recreate the callback, which
|
||||
// would propagate into GitPullDialog's onPulled prop and ultimately re-trigger
|
||||
// the pull-check effect inside the dialog (causing the flow to run twice).
|
||||
const pullDialogWorktreeRef = useRef(pullDialogWorktree);
|
||||
useEffect(() => {
|
||||
pullDialogWorktreeRef.current = pullDialogWorktree;
|
||||
}, [pullDialogWorktree]);
|
||||
|
||||
// Handle pull completed - refresh branches and worktrees
|
||||
const handlePullCompleted = useCallback(() => {
|
||||
// Refresh branch data (ahead/behind counts, tracking) and worktree list
|
||||
// after GitPullDialog completes the pull operation
|
||||
if (pullDialogWorktree) {
|
||||
fetchBranches(pullDialogWorktree.path);
|
||||
if (pullDialogWorktreeRef.current) {
|
||||
fetchBranches(pullDialogWorktreeRef.current.path);
|
||||
}
|
||||
fetchWorktrees({ silent: true });
|
||||
}, [fetchWorktrees, fetchBranches, pullDialogWorktree]);
|
||||
}, [fetchWorktrees, fetchBranches]);
|
||||
|
||||
// Wrapper for onCommit that works with the pull dialog's simpler WorktreeInfo.
|
||||
// Uses the full pullDialogWorktree when available (via ref to avoid making it
|
||||
// a dep that would cascade into handleSuccessfulPull → checkForLocalChanges recreations).
|
||||
const handleCommitMerge = useCallback(
|
||||
(_simpleWorktree: { path: string; branch: string; isMain: boolean }) => {
|
||||
// Prefer the full worktree object we already have (from ref)
|
||||
if (pullDialogWorktreeRef.current) {
|
||||
onCommit(pullDialogWorktreeRef.current);
|
||||
}
|
||||
},
|
||||
[onCommit]
|
||||
);
|
||||
|
||||
// Handle pull with remote selection when multiple remotes exist
|
||||
// Now opens the pull dialog which handles stash management and conflict resolution
|
||||
const handlePullWithRemoteSelection = useCallback(async (worktree: WorktreeInfo) => {
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes - show selection dialog first
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('pull');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
} else if (result.success && result.result && result.result.remotes.length === 1) {
|
||||
// Exactly one remote - open pull dialog directly with that remote
|
||||
const remoteName = result.result.remotes[0].name;
|
||||
setPullDialogRemote(remoteName);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
} else {
|
||||
// No remotes - open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
} catch {
|
||||
// If listing remotes fails, open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle push with remote selection when multiple remotes exist
|
||||
const handlePushWithRemoteSelection = useCallback(
|
||||
// If the branch has a tracked remote, pull from it directly (skip the remote selection dialog)
|
||||
const handlePullWithRemoteSelection = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
// If the branch already tracks a remote, pull from it directly — no dialog needed
|
||||
const tracked = getTrackingRemote(worktree.path);
|
||||
if (tracked) {
|
||||
setPullDialogRemote(tracked);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes - show selection dialog
|
||||
// Multiple remotes and no tracking remote - show selection dialog
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('pull');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
} else if (result.success && result.result && result.result.remotes.length === 1) {
|
||||
// Exactly one remote - open pull dialog directly with that remote
|
||||
const remoteName = result.result.remotes[0].name;
|
||||
setPullDialogRemote(remoteName);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
} else {
|
||||
// No remotes - open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
} catch {
|
||||
// If listing remotes fails, open pull dialog with default
|
||||
setPullDialogRemote(undefined);
|
||||
setPullDialogWorktree(worktree);
|
||||
setPullDialogOpen(true);
|
||||
}
|
||||
},
|
||||
[getTrackingRemote]
|
||||
);
|
||||
|
||||
// Handle push with remote selection when multiple remotes exist
|
||||
// If the branch has a tracked remote, push to it directly (skip the remote selection dialog)
|
||||
const handlePushWithRemoteSelection = useCallback(
|
||||
async (worktree: WorktreeInfo) => {
|
||||
// If the branch already tracks a remote, push to it directly — no dialog needed
|
||||
const tracked = getTrackingRemote(worktree.path);
|
||||
if (tracked) {
|
||||
handlePush(worktree, tracked);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const api = getHttpApiClient();
|
||||
const result = await api.worktree.listRemotes(worktree.path);
|
||||
|
||||
if (result.success && result.result && result.result.remotes.length > 1) {
|
||||
// Multiple remotes and no tracking remote - show selection dialog
|
||||
setSelectRemoteWorktree(worktree);
|
||||
setSelectRemoteOperation('push');
|
||||
setSelectRemoteDialogOpen(true);
|
||||
@@ -713,7 +757,7 @@ export function WorktreePanel({
|
||||
handlePush(worktree);
|
||||
}
|
||||
},
|
||||
[handlePush]
|
||||
[handlePush, getTrackingRemote]
|
||||
);
|
||||
|
||||
// Handle confirming remote selection for pull/push
|
||||
@@ -992,6 +1036,7 @@ export function WorktreePanel({
|
||||
remote={pullDialogRemote}
|
||||
onPulled={handlePullCompleted}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
onCommitMerge={handleCommitMerge}
|
||||
/>
|
||||
|
||||
{/* Dev Server Logs Panel */}
|
||||
@@ -1445,6 +1490,7 @@ export function WorktreePanel({
|
||||
onOpenChange={setViewStashesDialogOpen}
|
||||
worktree={viewStashesWorktree}
|
||||
onStashApplied={handleStashApplied}
|
||||
onStashApplyConflict={onStashApplyConflict}
|
||||
/>
|
||||
|
||||
{/* Cherry Pick Dialog */}
|
||||
@@ -1463,6 +1509,7 @@ export function WorktreePanel({
|
||||
worktree={pullDialogWorktree}
|
||||
remote={pullDialogRemote}
|
||||
onPulled={handlePullCompleted}
|
||||
onCommitMerge={handleCommitMerge}
|
||||
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user