mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-20 11:03:08 +00:00
feat: Address review comments, add stage/unstage functionality, conflict resolution improvements, support for Sonnet 4.6
This commit is contained in:
@@ -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> — Creates a task to analyze and resolve
|
||||
conflicts automatically
|
||||
</li>
|
||||
<li>
|
||||
<strong>Resolve Manually</strong> — 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user