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>