feat: Address review comments, add stage/unstage functionality, conflict resolution improvements, support for Sonnet 4.6

This commit is contained in:
gsxdsm
2026-02-18 18:58:33 -08:00
parent df9a6314da
commit 983eb21faa
66 changed files with 2317 additions and 823 deletions

View File

@@ -0,0 +1,143 @@
/**
* Dialog shown when a branch switch or stash-pop operation results in merge conflicts.
* Presents the user with two options:
* 1. Resolve Manually - leaves conflict markers in place
* 2. Resolve with AI - creates a feature task for AI-powered conflict resolution
*
* This dialog ensures the user can choose how to handle the conflict instead of
* automatically creating and starting an AI task.
*/
import { useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle, Wrench, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import type { BranchSwitchConflictInfo, StashPopConflictInfo } from '../worktree-panel/types';
export type BranchConflictType = 'branch-switch' | 'stash-pop';
export type BranchConflictData =
| { type: 'branch-switch'; info: BranchSwitchConflictInfo }
| { type: 'stash-pop'; info: StashPopConflictInfo };
interface BranchConflictDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
conflictData: BranchConflictData | null;
onResolveWithAI?: (conflictData: BranchConflictData) => void;
}
export function BranchConflictDialog({
open,
onOpenChange,
conflictData,
onResolveWithAI,
}: BranchConflictDialogProps) {
const handleResolveManually = useCallback(() => {
toast.info('Conflict markers left in place', {
description: 'Edit the conflicting files to resolve conflicts manually.',
duration: 6000,
});
onOpenChange(false);
}, [onOpenChange]);
const handleResolveWithAI = useCallback(() => {
if (!conflictData || !onResolveWithAI) return;
onResolveWithAI(conflictData);
onOpenChange(false);
}, [conflictData, onResolveWithAI, onOpenChange]);
if (!conflictData) return null;
const isBranchSwitch = conflictData.type === 'branch-switch';
const branchName = isBranchSwitch ? conflictData.info.branchName : conflictData.info.branchName;
const description = isBranchSwitch ? (
<>
Merge conflicts occurred when switching from{' '}
<code className="font-mono bg-muted px-1 rounded">
{(conflictData.info as BranchSwitchConflictInfo).previousBranch}
</code>{' '}
to <code className="font-mono bg-muted px-1 rounded">{branchName}</code>. Local changes were
stashed before switching and reapplying them caused conflicts.
</>
) : (
<>
The branch switch to <code className="font-mono bg-muted px-1 rounded">{branchName}</code>{' '}
failed and restoring the previously stashed local changes resulted in merge conflicts.
</>
);
const title = isBranchSwitch
? 'Branch Switch Conflicts Detected'
: 'Stash Restore Conflicts Detected';
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
{title}
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<span className="block">{description}</span>
{!isBranchSwitch &&
(conflictData.info as StashPopConflictInfo).stashPopConflictMessage && (
<div className="flex items-start gap-2 p-3 rounded-md bg-orange-500/10 border border-orange-500/20">
<AlertTriangle className="w-4 h-4 text-orange-500 mt-0.5 flex-shrink-0" />
<span className="text-orange-500 text-sm">
{(conflictData.info as StashPopConflictInfo).stashPopConflictMessage}
</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 resolve:
</p>
<ul className="text-sm text-muted-foreground list-disc list-inside space-y-1">
<li>
<strong>Resolve with AI</strong> &mdash; Creates a task to analyze and resolve
conflicts automatically
</li>
<li>
<strong>Resolve Manually</strong> &mdash; Leaves conflict markers in place for
you to edit directly
</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleResolveManually}>
<Wrench className="w-4 h-4 mr-2" />
Resolve Manually
</Button>
{onResolveWithAI && (
<Button
onClick={handleResolveWithAI}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Sparkles className="w-4 h-4 mr-2" />
Resolve with AI
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -411,7 +411,7 @@ export function CherryPickDialog({
sourceBranch: selectedBranch,
targetBranch: conflictInfo.targetBranch,
targetWorktreePath: conflictInfo.targetWorktreePath,
operationType: 'merge',
operationType: 'cherry-pick',
});
onOpenChange(false);
}
@@ -461,7 +461,7 @@ export function CherryPickDialog({
Cherry-pick the selected commit(s) from{' '}
<code className="font-mono bg-muted px-0.5 rounded">{selectedBranch}</code>
</li>
<li>Resolve any merge conflicts</li>
<li>Resolve any cherry-pick conflicts</li>
<li>Ensure the code compiles and tests pass</li>
</ul>
</div>

View File

@@ -29,6 +29,7 @@ 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 { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface WorktreeInfo {
path: string;
@@ -45,23 +46,6 @@ interface CommitWorktreeDialogProps {
onCommitted: () => void;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
@@ -119,102 +103,7 @@ const getStatusBadgeColor = (status: string) => {
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
// Skip trailing empty line produced by split('\n') to avoid phantom context line
if (line === '' && i === lines.length - 1) {
continue;
}
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
// parseDiff is imported from @/lib/diff-utils
function DiffLine({
type,
@@ -323,8 +212,20 @@ export function CommitWorktreeDialog({
const fileList = result.files ?? [];
if (!cancelled) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? '');
// Select all files by default
if (!cancelled) setSelectedFiles(new Set(fileList.map((f) => f.path)));
// If any files are already staged, pre-select only staged files
// Otherwise select all files by default
const stagedFiles = fileList.filter((f) => {
const idx = f.indexStatus ?? ' ';
return idx !== ' ' && idx !== '?';
});
if (!cancelled) {
if (stagedFiles.length > 0) {
// Also include untracked files that are staged (A status)
setSelectedFiles(new Set(stagedFiles.map((f) => f.path)));
} else {
setSelectedFiles(new Set(fileList.map((f) => f.path)));
}
}
}
}
} catch (err) {
@@ -532,18 +433,14 @@ export function CommitWorktreeDialog({
const isChecked = selectedFiles.has(file.path);
const isExpanded = expandedFile === file.path;
const fileDiff = diffsByFile.get(file.path);
const additions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'addition').length,
0
)
: 0;
const deletions = fileDiff
? fileDiff.hunks.reduce(
(acc, hunk) => acc + hunk.lines.filter((l) => l.type === 'deletion').length,
0
)
: 0;
const additions = fileDiff?.additions ?? 0;
const deletions = fileDiff?.deletions ?? 0;
// Determine staging state from index/worktree status
const idx = file.indexStatus ?? ' ';
const wt = file.workTreeStatus ?? ' ';
const isStaged = idx !== ' ' && idx !== '?';
const isUnstaged = wt !== ' ' && wt !== '?';
const isUntracked = idx === '?' && wt === '?';
return (
<div key={file.path} className="border-b border-border last:border-b-0">
@@ -583,6 +480,16 @@ export function CommitWorktreeDialog({
>
{getStatusLabel(file.status)}
</span>
{isStaged && !isUntracked && (
<span className="text-[10px] px-1 py-0.5 rounded border font-medium flex-shrink-0 bg-green-500/15 text-green-400 border-green-500/30">
Staged
</span>
)}
{isStaged && isUnstaged && (
<span className="text-[10px] px-1 py-0.5 rounded border font-medium flex-shrink-0 bg-amber-500/15 text-amber-400 border-amber-500/30">
Partial
</span>
)}
{additions > 0 && (
<span className="text-[10px] text-green-400 flex-shrink-0">
+{additions}

View File

@@ -28,6 +28,7 @@ import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import type { FileStatus } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface WorktreeInfo {
path: string;
@@ -44,23 +45,6 @@ interface DiscardWorktreeChangesDialogProps {
onDiscarded: () => void;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
@@ -118,98 +102,7 @@ const getStatusBadgeColor = (status: string) => {
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
// parseDiff is imported from @/lib/diff-utils
function DiffLine({
type,

View File

@@ -76,17 +76,6 @@ export function GitPullDialog({
const [pullResult, setPullResult] = useState<PullResult | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Reset state when dialog opens
useEffect(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
}
}, [open, worktree]); // eslint-disable-line react-hooks/exhaustive-deps
const checkForLocalChanges = useCallback(async () => {
if (!worktree) return;
@@ -129,6 +118,17 @@ export function GitPullDialog({
}
}, [worktree, remote, onPulled]);
// Reset state when dialog opens
useEffect(() => {
if (open && worktree) {
setPhase('checking');
setPullResult(null);
setErrorMessage(null);
// Start the initial check
checkForLocalChanges();
}
}, [open, worktree, checkForLocalChanges]);
const handlePullWithStash = useCallback(async () => {
if (!worktree) return;
@@ -154,9 +154,14 @@ export function GitPullDialog({
if (result.result?.hasConflicts) {
setPhase('conflict');
} else {
} else if (result.result?.pulled) {
setPhase('success');
onPulled?.();
} else {
// Unrecognized response: no pulled flag and no conflicts
console.warn('handlePullWithStash: unrecognized response', result.result);
setErrorMessage('Unexpected pull response');
setPhase('error');
}
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : 'Failed to pull');
@@ -300,14 +305,16 @@ export function GitPullDialog({
{pullResult?.message || 'Changes pulled successfully'}
</span>
{pullResult?.stashed && pullResult?.stashRestored && (
<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>
)}
{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>
)}
{pullResult?.stashed &&
(!pullResult?.stashRestored || pullResult?.stashRecoveryFailed) && (

View File

@@ -24,3 +24,8 @@ export { ViewStashesDialog } from './view-stashes-dialog';
export { StashApplyConflictDialog } from './stash-apply-conflict-dialog';
export { CherryPickDialog } from './cherry-pick-dialog';
export { GitPullDialog } from './git-pull-dialog';
export {
BranchConflictDialog,
type BranchConflictData,
type BranchConflictType,
} from './branch-conflict-dialog';

View File

@@ -60,11 +60,6 @@ interface MergeRebaseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onConfirm: (
worktree: WorktreeInfo,
remoteBranch: string,
strategy: PullStrategy
) => void | Promise<void>;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
@@ -72,7 +67,6 @@ export function MergeRebaseDialog({
open,
onOpenChange,
worktree,
onConfirm,
onCreateConflictResolutionFeature,
}: MergeRebaseDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
@@ -222,9 +216,6 @@ export function MergeRebaseDialog({
strategy: 'rebase',
});
setStep('conflict');
toast.error('Rebase conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.error('Rebase failed', {
description: result.error || 'Unknown error',
@@ -245,9 +236,6 @@ export function MergeRebaseDialog({
strategy: 'merge',
});
setStep('conflict');
toast.error('Merge conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
toast.success(`Merged ${selectedBranch}`, {
description: result.result.message || 'Merge completed successfully',
@@ -268,53 +256,30 @@ export function MergeRebaseDialog({
strategy: 'merge',
});
setStep('conflict');
toast.error('Merge conflicts detected', {
description: 'Choose how to resolve the conflicts below.',
});
} else {
// Non-conflict failure - fall back to creating a feature task
toast.info('Direct operation failed, creating AI task instead', {
description: result.error || 'The operation will be handled by an AI agent.',
// Non-conflict failure - show conflict resolution UI so user can choose
// how to handle it (resolve manually or with AI) rather than auto-creating a task
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: 'merge',
});
try {
await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
} catch (err) {
logger.error('Failed to create feature task:', err);
setStep('select');
}
setStep('conflict');
}
}
}
} catch (err) {
logger.error('Failed to execute operation:', err);
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') || errorMessage.includes('CONFLICT');
if (hasConflicts) {
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: selectedStrategy,
});
setStep('conflict');
} else {
// Fall back to creating a feature task
toast.info('Creating AI task to handle the operation', {
description: 'The operation will be performed by an AI agent.',
});
try {
await onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
} catch (confirmErr) {
logger.error('Failed to create feature task:', confirmErr);
toast.error('Operation failed', { description: errorMessage });
setStep('select');
}
}
// Show conflict resolution UI so user can choose how to handle it
setConflictState({
conflictFiles: [],
remoteBranch: selectedBranch,
strategy: selectedStrategy,
});
setStep('conflict');
}
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, onConfirm, onOpenChange]);
}, [worktree, selectedBranch, selectedStrategy, selectedRemote, onOpenChange]);
const handleResolveWithAI = useCallback(() => {
if (!worktree || !conflictState) return;
@@ -329,13 +294,10 @@ export function MergeRebaseDialog({
};
onCreateConflictResolutionFeature(conflictInfo);
onOpenChange(false);
} else {
// Fallback: create via the onConfirm handler
onConfirm(worktree, conflictState.remoteBranch, conflictState.strategy);
onOpenChange(false);
}
}, [worktree, conflictState, onCreateConflictResolutionFeature, onConfirm, onOpenChange]);
onOpenChange(false);
}, [worktree, conflictState, onCreateConflictResolutionFeature, onOpenChange]);
const handleResolveManually = useCallback(() => {
toast.info('Conflict markers left in place', {

View File

@@ -27,6 +27,7 @@ import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { TruncatedFilePath } from '@/components/ui/truncated-file-path';
import type { FileStatus } from '@/types/electron';
import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils';
interface WorktreeInfo {
path: string;
@@ -43,23 +44,6 @@ interface StashChangesDialogProps {
onStashed?: () => void;
}
interface ParsedDiffHunk {
header: string;
lines: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}[];
}
interface ParsedFileDiff {
filePath: string;
hunks: ParsedDiffHunk[];
isNew?: boolean;
isDeleted?: boolean;
isRenamed?: boolean;
}
const getFileIcon = (status: string) => {
switch (status) {
case 'A':
@@ -117,101 +101,7 @@ const getStatusBadgeColor = (status: string) => {
}
};
/**
* Parse unified diff format into structured data
*/
function parseDiff(diffText: string): ParsedFileDiff[] {
if (!diffText) return [];
const files: ParsedFileDiff[] = [];
const lines = diffText.split('\n');
let currentFile: ParsedFileDiff | null = null;
let currentHunk: ParsedDiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip trailing empty string produced by a final newline in diffText
if (line === '' && i === lines.length - 1) continue;
if (line.startsWith('diff --git')) {
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
const match = line.match(/diff --git a\/(.*?) b\/(.*)/);
currentFile = {
filePath: match ? match[2] : 'unknown',
hunks: [],
};
currentHunk = null;
continue;
}
if (line.startsWith('new file mode')) {
if (currentFile) currentFile.isNew = true;
continue;
}
if (line.startsWith('deleted file mode')) {
if (currentFile) currentFile.isDeleted = true;
continue;
}
if (line.startsWith('rename from') || line.startsWith('rename to')) {
if (currentFile) currentFile.isRenamed = true;
continue;
}
if (line.startsWith('index ') || line.startsWith('--- ') || line.startsWith('+++ ')) {
continue;
}
if (line.startsWith('@@')) {
if (currentHunk && currentFile) currentFile.hunks.push(currentHunk);
const hunkMatch = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
oldLineNum = hunkMatch ? parseInt(hunkMatch[1], 10) : 1;
newLineNum = hunkMatch ? parseInt(hunkMatch[2], 10) : 1;
currentHunk = {
header: line,
lines: [{ type: 'header', content: line }],
};
continue;
}
if (currentHunk) {
if (line.startsWith('+')) {
currentHunk.lines.push({
type: 'addition',
content: line.substring(1),
lineNumber: { new: newLineNum },
});
newLineNum++;
} else if (line.startsWith('-')) {
currentHunk.lines.push({
type: 'deletion',
content: line.substring(1),
lineNumber: { old: oldLineNum },
});
oldLineNum++;
} else if (line.startsWith(' ') || line === '') {
currentHunk.lines.push({
type: 'context',
content: line.substring(1) || '',
lineNumber: { old: oldLineNum, new: newLineNum },
});
oldLineNum++;
newLineNum++;
}
}
}
if (currentFile) {
if (currentHunk) currentFile.hunks.push(currentHunk);
files.push(currentFile);
}
return files;
}
// parseDiff is imported from @/lib/diff-utils
function DiffLine({
type,
@@ -316,6 +206,8 @@ export function StashChangesDialog({
// Select all files by default
if (!cancelled.current)
setSelectedFiles(new Set(fileList.map((f: FileStatus) => f.path)));
} else if (!cancelled.current) {
setLoadDiffsError(result.error ?? 'Failed to load diffs');
}
} catch (err) {
console.warn('Failed to load diffs for stash dialog:', err);
@@ -365,7 +257,7 @@ export function StashChangesDialog({
setExpandedFile((prev) => (prev === filePath ? null : filePath));
}, []);
const handleStash = async () => {
const handleStash = useCallback(async () => {
if (!worktree || selectedFiles.size === 0) return;
setIsStashing(true);
@@ -405,14 +297,17 @@ export function StashChangesDialog({
} finally {
setIsStashing(false);
}
};
}, [worktree, selectedFiles, files.length, message, onOpenChange, onStashed]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isStashing && selectedFiles.size > 0) {
e.preventDefault();
handleStash();
}
};
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isStashing && selectedFiles.size > 0) {
e.preventDefault();
handleStash();
}
},
[isStashing, selectedFiles.size, handleStash]
);
if (!worktree) return null;
@@ -614,7 +509,13 @@ export function StashChangesDialog({
<p className="text-xs text-muted-foreground">
A descriptive message helps identify this stash later. Press{' '}
<kbd className="px-1 py-0.5 text-[10px] bg-muted rounded border">
{navigator.platform.includes('Mac') ? '⌘' : 'Ctrl'}+Enter
{typeof navigator !== 'undefined' &&
((navigator as any).userAgentData?.platform || navigator.platform || '').includes(
'Mac'
)
? '⌘'
: 'Ctrl'}
+Enter
</kbd>{' '}
to stash.
</p>

View File

@@ -48,6 +48,9 @@ export function ViewWorktreeChangesDialog({
{worktree.changedFilesCount > 1 ? 's' : ''} changed)
</span>
)}
<span className="ml-1 text-xs text-muted-foreground">
Use the Stage/Unstage buttons to prepare files for commit.
</span>
</DialogDescription>
</DialogHeader>
@@ -58,6 +61,8 @@ export function ViewWorktreeChangesDialog({
featureId={worktree.branch}
useWorktrees={true}
compact={false}
enableStaging={true}
worktreePath={worktree.path}
className="mt-4"
/>
</div>

View File

@@ -37,6 +37,9 @@ import {
History,
Archive,
Cherry,
AlertTriangle,
XCircle,
CheckCircle,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
@@ -112,6 +115,10 @@ interface WorktreeActionsDropdownProps {
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
/** Abort an in-progress merge/rebase/cherry-pick */
onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -162,6 +169,8 @@ export function WorktreeActionsDropdown({
onStashChanges,
onViewStashes,
onCherryPick,
onAbortOperation,
onContinueOperation,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -233,6 +242,61 @@ export function WorktreeActionsDropdown({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{/* Conflict indicator and actions when merge/rebase/cherry-pick is in progress */}
{worktree.hasConflicts && (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-red-600 dark:text-red-400">
<AlertTriangle className="w-3.5 h-3.5" />
{worktree.conflictType === 'merge'
? 'Merge'
: worktree.conflictType === 'rebase'
? 'Rebase'
: worktree.conflictType === 'cherry-pick'
? 'Cherry-pick'
: 'Operation'}{' '}
Conflicts
{worktree.conflictFiles && worktree.conflictFiles.length > 0 && (
<span className="ml-auto text-[10px] bg-red-500/20 text-red-600 dark:text-red-400 px-1.5 py-0.5 rounded">
{worktree.conflictFiles.length} file
{worktree.conflictFiles.length !== 1 ? 's' : ''}
</span>
)}
</DropdownMenuLabel>
{onAbortOperation && (
<DropdownMenuItem
onClick={() => onAbortOperation(worktree)}
className="text-xs text-destructive focus:text-destructive"
>
<XCircle className="w-3.5 h-3.5 mr-2" />
Abort{' '}
{worktree.conflictType === 'merge'
? 'Merge'
: worktree.conflictType === 'rebase'
? 'Rebase'
: worktree.conflictType === 'cherry-pick'
? 'Cherry-pick'
: 'Operation'}
</DropdownMenuItem>
)}
{onContinueOperation && (
<DropdownMenuItem
onClick={() => onContinueOperation(worktree)}
className="text-xs text-green-600 focus:text-green-700"
>
<CheckCircle className="w-3.5 h-3.5 mr-2" />
Continue{' '}
{worktree.conflictType === 'merge'
? 'Merge'
: worktree.conflictType === 'rebase'
? 'Rebase'
: worktree.conflictType === 'cherry-pick'
? 'Cherry-pick'
: 'Operation'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
{/* Loading indicator while git status is being determined */}
{isLoadingGitStatus && (
<>

View File

@@ -1,6 +1,6 @@
import { DropdownMenuItem } from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical } from 'lucide-react';
import { Check, CircleDot, Globe, GitPullRequest, FlaskConical, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, DevServerInfo, TestSessionInfo } from '../types';
@@ -8,6 +8,8 @@ import {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getConflictBadgeStyles,
getConflictTypeLabel,
getTestStatusStyles,
} from './worktree-indicator-utils';
@@ -182,6 +184,20 @@ export function WorktreeDropdownItem({
</span>
)}
{/* Conflict indicator */}
{worktree.hasConflicts && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
getConflictBadgeStyles()
)}
title={`${getConflictTypeLabel(worktree.conflictType)} conflicts${worktree.conflictFiles?.length ? ` (${worktree.conflictFiles.length} files)` : ''}`}
>
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
{getConflictTypeLabel(worktree.conflictType)}
</span>
)}
{/* PR indicator */}
{pr && (
<span

View File

@@ -16,6 +16,7 @@ import {
Globe,
GitPullRequest,
FlaskConical,
AlertTriangle,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
@@ -34,6 +35,8 @@ import {
truncateBranchName,
getPRBadgeStyles,
getChangesBadgeStyles,
getConflictBadgeStyles,
getConflictTypeLabel,
getTestStatusStyles,
} from './worktree-indicator-utils';
@@ -114,6 +117,10 @@ export interface WorktreeDropdownProps {
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
/** Abort an in-progress merge/rebase/cherry-pick */
onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void;
}
/**
@@ -195,6 +202,8 @@ export function WorktreeDropdown({
onStashChanges,
onViewStashes,
onCherryPick,
onAbortOperation,
onContinueOperation,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -323,6 +332,20 @@ export function WorktreeDropdown({
</span>
)}
{/* Conflict indicator */}
{selectedWorktree?.hasConflicts && (
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded border shrink-0',
getConflictBadgeStyles()
)}
title={`${getConflictTypeLabel(selectedWorktree.conflictType)} conflicts detected`}
>
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
{getConflictTypeLabel(selectedWorktree.conflictType)}
</span>
)}
{/* PR badge */}
{selectedWorktree?.pr && (
<span
@@ -487,6 +510,8 @@ export function WorktreeDropdown({
onStashChanges={onStashChanges}
onViewStashes={onViewStashes}
onCherryPick={onCherryPick}
onAbortOperation={onAbortOperation}
onContinueOperation={onContinueOperation}
hasInitScript={hasInitScript}
/>
)}

View File

@@ -46,6 +46,30 @@ export function getChangesBadgeStyles(): string {
return 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30';
}
/**
* Returns the CSS classes for the conflict indicator badge.
* Uses red/destructive colors to indicate merge/rebase/cherry-pick conflicts.
*/
export function getConflictBadgeStyles(): string {
return 'bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/30';
}
/**
* Returns a human-readable label for the conflict type.
*/
export function getConflictTypeLabel(conflictType?: 'merge' | 'rebase' | 'cherry-pick'): string {
switch (conflictType) {
case 'merge':
return 'Merge';
case 'rebase':
return 'Rebase';
case 'cherry-pick':
return 'Cherry-pick';
default:
return 'Conflict';
}
}
/** Possible test session status values */
export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled';

View File

@@ -1,6 +1,6 @@
import type { JSX } from 'react';
import { Button } from '@/components/ui/button';
import { Globe, CircleDot, GitPullRequest } from 'lucide-react';
import { Globe, CircleDot, GitPullRequest, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -15,6 +15,7 @@ import type {
} from '../types';
import { BranchSwitchDropdown } from './branch-switch-dropdown';
import { WorktreeActionsDropdown } from './worktree-actions-dropdown';
import { getConflictBadgeStyles, getConflictTypeLabel } from './worktree-indicator-utils';
interface WorktreeTabProps {
worktree: WorktreeInfo;
@@ -85,6 +86,10 @@ interface WorktreeTabProps {
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
/** Abort an in-progress merge/rebase/cherry-pick */
onAbortOperation?: (worktree: WorktreeInfo) => void;
/** Continue an in-progress merge/rebase/cherry-pick after resolving conflicts */
onContinueOperation?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
/** Whether a test command is configured in project settings */
hasTestCommand?: boolean;
@@ -149,6 +154,8 @@ export function WorktreeTab({
onStashChanges,
onViewStashes,
onCherryPick,
onAbortOperation,
onContinueOperation,
hasInitScript,
hasTestCommand = false,
}: WorktreeTabProps) {
@@ -304,6 +311,29 @@ export function WorktreeTab({
</TooltipContent>
</Tooltip>
)}
{worktree.hasConflicts && (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected ? 'bg-red-500 text-white border-red-400' : getConflictBadgeStyles()
)}
>
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
{getConflictTypeLabel(worktree.conflictType)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{getConflictTypeLabel(worktree.conflictType)} conflicts detected
{worktree.conflictFiles && worktree.conflictFiles.length > 0
? ` (${worktree.conflictFiles.length} file${worktree.conflictFiles.length !== 1 ? 's' : ''})`
: ''}
</p>
</TooltipContent>
</Tooltip>
)}
{prBadge}
</Button>
<BranchSwitchDropdown
@@ -371,6 +401,29 @@ export function WorktreeTab({
</TooltipContent>
</Tooltip>
)}
{worktree.hasConflicts && (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
isSelected ? 'bg-red-500 text-white border-red-400' : getConflictBadgeStyles()
)}
>
<AlertTriangle className="w-2.5 h-2.5 mr-0.5" />
{getConflictTypeLabel(worktree.conflictType)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{getConflictTypeLabel(worktree.conflictType)} conflicts detected
{worktree.conflictFiles && worktree.conflictFiles.length > 0
? ` (${worktree.conflictFiles.length} file${worktree.conflictFiles.length !== 1 ? 's' : ''})`
: ''}
</p>
</TooltipContent>
</Tooltip>
)}
{prBadge}
</Button>
)}
@@ -463,6 +516,8 @@ export function WorktreeTab({
onStashChanges={onStashChanges}
onViewStashes={onViewStashes}
onCherryPick={onCherryPick}
onAbortOperation={onAbortOperation}
onContinueOperation={onContinueOperation}
hasInitScript={hasInitScript}
/>
</div>

View File

@@ -11,6 +11,12 @@ export interface WorktreeInfo {
hasChanges?: boolean;
changedFilesCount?: number;
pr?: WorktreePRInfo;
/** Whether a merge, rebase, or cherry-pick is in progress with conflicts */
hasConflicts?: boolean;
/** Type of conflict operation in progress */
conflictType?: 'merge' | 'rebase' | 'cherry-pick';
/** List of files with conflicts */
conflictFiles?: string[];
}
export interface BranchInfo {
@@ -81,7 +87,7 @@ export interface MergeConflictInfo {
/** List of files with conflicts, if available */
conflictFiles?: string[];
/** Type of operation that caused the conflict */
operationType?: 'merge' | 'rebase';
operationType?: 'merge' | 'rebase' | 'cherry-pick';
}
export interface BranchSwitchConflictInfo {

View File

@@ -542,6 +542,48 @@ export function WorktreePanel({
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle aborting an in-progress merge/rebase/cherry-pick
const handleAbortOperation = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.abortOperation(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message || 'Operation aborted successfully');
fetchWorktrees({ silent: true });
} else {
toast.error(result.error || 'Failed to abort operation');
}
} catch (error) {
toast.error('Failed to abort operation', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
},
[fetchWorktrees]
);
// Handle continuing an in-progress merge/rebase/cherry-pick after conflict resolution
const handleContinueOperation = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.continueOperation(worktree.path);
if (result.success && result.result) {
toast.success(result.result.message || 'Operation continued successfully');
fetchWorktrees({ silent: true });
} else {
toast.error(result.error || 'Failed to continue operation');
}
} catch (error) {
toast.error('Failed to continue operation', {
description: error instanceof Error ? error.message : 'Unknown error',
});
}
},
[fetchWorktrees]
);
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
setLogPanelWorktree(worktree);
@@ -771,6 +813,8 @@ export function WorktreePanel({
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
/>
)}
@@ -989,6 +1033,8 @@ export function WorktreePanel({
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
/>
{useWorktreesEnabled && (
@@ -1086,6 +1132,8 @@ export function WorktreePanel({
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
@@ -1163,6 +1211,8 @@ export function WorktreePanel({
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
onAbortOperation={handleAbortOperation}
onContinueOperation={handleContinueOperation}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>