mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-17 22:13:08 +00:00
feat: Address review comments, add stage/unstage functionality, conflict resolution improvements, support for Sonnet 4.6
This commit is contained in:
@@ -56,7 +56,7 @@ import {
|
||||
PlanApprovalDialog,
|
||||
MergeRebaseDialog,
|
||||
} from './board-view/dialogs';
|
||||
import type { DependencyLinkType, PullStrategy } from './board-view/dialogs';
|
||||
import type { DependencyLinkType } from './board-view/dialogs';
|
||||
import { PipelineSettingsDialog } from './board-view/dialogs/pipeline-settings-dialog';
|
||||
import { CreateWorktreeDialog } from './board-view/dialogs/create-worktree-dialog';
|
||||
import { DeleteWorktreeDialog } from './board-view/dialogs/delete-worktree-dialog';
|
||||
@@ -87,7 +87,8 @@ import {
|
||||
useListViewState,
|
||||
} from './board-view/hooks';
|
||||
import { SelectionActionBar, ListView } from './board-view/components';
|
||||
import { MassEditDialog } from './board-view/dialogs';
|
||||
import { MassEditDialog, BranchConflictDialog } from './board-view/dialogs';
|
||||
import type { BranchConflictData } from './board-view/dialogs';
|
||||
import { InitScriptIndicator } from './board-view/init-script-indicator';
|
||||
import { useInitScriptEvents } from '@/hooks/use-init-script-events';
|
||||
import { usePipelineConfig } from '@/hooks/queries';
|
||||
@@ -189,6 +190,10 @@ export function BoardView() {
|
||||
);
|
||||
const [worktreeRefreshKey, setWorktreeRefreshKey] = useState(0);
|
||||
|
||||
// Branch conflict dialog state (for branch switch and stash pop conflicts)
|
||||
const [branchConflictData, setBranchConflictData] = useState<BranchConflictData | null>(null);
|
||||
const [showBranchConflictDialog, setShowBranchConflictDialog] = useState(false);
|
||||
|
||||
// Backlog plan dialog state
|
||||
const [showPlanDialog, setShowPlanDialog] = useState(false);
|
||||
const [pendingBacklogPlan, setPendingBacklogPlan] = useState<BacklogPlanResult | null>(null);
|
||||
@@ -935,56 +940,29 @@ export function BoardView() {
|
||||
setShowMergeRebaseDialog(true);
|
||||
}, []);
|
||||
|
||||
// Handler called when user confirms the merge & rebase dialog
|
||||
const handleConfirmResolveConflicts = useCallback(
|
||||
async (worktree: WorktreeInfo, remoteBranch: string, strategy: PullStrategy) => {
|
||||
const isRebase = strategy === 'rebase';
|
||||
|
||||
const description = isRebase
|
||||
? `Fetch the latest changes from ${remoteBranch} and rebase the current branch (${worktree.branch}) onto ${remoteBranch}. Use "git fetch" followed by "git rebase ${remoteBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.`
|
||||
: `Pull latest from ${remoteBranch} and resolve conflicts. Merge ${remoteBranch} into the current branch (${worktree.branch}), resolving any merge conflicts that arise. After resolving conflicts, ensure the code compiles and tests pass.`;
|
||||
|
||||
const title = isRebase
|
||||
? `Rebase & Resolve Conflicts: ${worktree.branch} onto ${remoteBranch}`
|
||||
: `Resolve Merge Conflicts: ${remoteBranch} → ${worktree.branch}`;
|
||||
|
||||
const featureData = {
|
||||
title,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: 'opus' as const,
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: worktree.branch,
|
||||
workMode: 'custom' as const, // Use the worktree's branch
|
||||
priority: 1, // High priority for conflict resolution
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
await handleAddAndStartFeature(featureData);
|
||||
},
|
||||
[handleAddAndStartFeature, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler called when merge/rebase fails due to conflicts and user wants to create a feature to resolve them
|
||||
const handleCreateMergeConflictResolutionFeature = useCallback(
|
||||
async (conflictInfo: MergeConflictInfo) => {
|
||||
const isRebase = conflictInfo.operationType === 'rebase';
|
||||
const isCherryPick = conflictInfo.operationType === 'cherry-pick';
|
||||
const conflictFilesInfo =
|
||||
conflictInfo.conflictFiles && conflictInfo.conflictFiles.length > 0
|
||||
? `\n\nConflicting files:\n${conflictInfo.conflictFiles.map((f) => `- ${f}`).join('\n')}`
|
||||
: '';
|
||||
|
||||
const description = isRebase
|
||||
? `Fetch the latest changes from ${conflictInfo.sourceBranch} and rebase the current branch (${conflictInfo.targetBranch}) onto ${conflictInfo.sourceBranch}. Use "git fetch" followed by "git rebase ${conflictInfo.sourceBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.${conflictFilesInfo}`
|
||||
: `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.${conflictFilesInfo}`;
|
||||
let description: string;
|
||||
let title: string;
|
||||
|
||||
const title = isRebase
|
||||
? `Rebase & Resolve Conflicts: ${conflictInfo.targetBranch} onto ${conflictInfo.sourceBranch}`
|
||||
: `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`;
|
||||
if (isRebase) {
|
||||
description = `Fetch the latest changes from ${conflictInfo.sourceBranch} and rebase the current branch (${conflictInfo.targetBranch}) onto ${conflictInfo.sourceBranch}. Use "git fetch" followed by "git rebase ${conflictInfo.sourceBranch}" to replay commits on top of the remote branch for a linear history. If rebase conflicts arise, resolve them one commit at a time using "git rebase --continue" after fixing each conflict. After completing the rebase, ensure the code compiles and tests pass.${conflictFilesInfo}`;
|
||||
title = `Rebase & Resolve Conflicts: ${conflictInfo.targetBranch} onto ${conflictInfo.sourceBranch}`;
|
||||
} else if (isCherryPick) {
|
||||
description = `Resolve cherry-pick conflicts when cherry-picking commits from "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The cherry-pick was attempted but encountered conflicts that need to be resolved manually. Cherry-pick the commits again using "git cherry-pick <commit-hashes>", resolve any conflicts, then use "git cherry-pick --continue" after fixing each conflict. After completing the cherry-pick, ensure the code compiles and tests pass.${conflictFilesInfo}`;
|
||||
title = `Resolve Cherry-Pick Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`;
|
||||
} else {
|
||||
description = `Resolve merge conflicts when merging "${conflictInfo.sourceBranch}" into "${conflictInfo.targetBranch}". The merge was started but encountered conflicts that need to be resolved manually. After resolving all conflicts, ensure the code compiles and tests pass, then complete the merge by committing the resolved changes.${conflictFilesInfo}`;
|
||||
title = `Resolve Merge Conflicts: ${conflictInfo.sourceBranch} → ${conflictInfo.targetBranch}`;
|
||||
}
|
||||
|
||||
const featureData = {
|
||||
title,
|
||||
@@ -1007,60 +985,72 @@ export function BoardView() {
|
||||
[handleAddAndStartFeature, defaultSkipTests]
|
||||
);
|
||||
|
||||
// Handler called when branch switch stash reapply causes merge conflicts
|
||||
const handleBranchSwitchConflict = useCallback(
|
||||
async (conflictInfo: BranchSwitchConflictInfo) => {
|
||||
const description = `Resolve merge conflicts that occurred when switching from "${conflictInfo.previousBranch}" to "${conflictInfo.branchName}". Local changes were stashed before switching and reapplying them caused conflicts. Please resolve all merge conflicts, ensure the code compiles and tests pass.`;
|
||||
|
||||
const featureData = {
|
||||
title: `Resolve Stash Conflicts: switch to ${conflictInfo.branchName}`,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: resolveModelString('opus'),
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: conflictInfo.branchName,
|
||||
workMode: 'custom' as const,
|
||||
priority: 1,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
await handleAddAndStartFeature(featureData);
|
||||
},
|
||||
[handleAddAndStartFeature, defaultSkipTests]
|
||||
);
|
||||
// Handler called when branch switch stash reapply causes merge conflicts.
|
||||
// Shows a dialog to let the user choose between manual or AI resolution.
|
||||
const handleBranchSwitchConflict = useCallback((conflictInfo: BranchSwitchConflictInfo) => {
|
||||
setBranchConflictData({ type: 'branch-switch', info: conflictInfo });
|
||||
setShowBranchConflictDialog(true);
|
||||
}, []);
|
||||
|
||||
// Handler called when checkout fails AND the stash-pop restoration produces merge conflicts.
|
||||
// Creates an AI-assisted board task to guide the user through resolving the conflicts.
|
||||
const handleStashPopConflict = useCallback(
|
||||
async (conflictInfo: StashPopConflictInfo) => {
|
||||
const description =
|
||||
`Resolve merge conflicts that occurred when attempting to switch to branch "${conflictInfo.branchName}". ` +
|
||||
`The checkout failed and, while restoring the previously stashed local changes, git reported merge conflicts. ` +
|
||||
`${conflictInfo.stashPopConflictMessage} ` +
|
||||
`Please review all conflicted files, resolve the conflicts, ensure the code compiles and tests pass, ` +
|
||||
`then re-attempt the branch switch.`;
|
||||
// Shows a dialog to let the user choose between manual or AI resolution.
|
||||
const handleStashPopConflict = useCallback((conflictInfo: StashPopConflictInfo) => {
|
||||
setBranchConflictData({ type: 'stash-pop', info: conflictInfo });
|
||||
setShowBranchConflictDialog(true);
|
||||
}, []);
|
||||
|
||||
const featureData = {
|
||||
title: `Resolve Stash-Pop Conflicts: branch switch to ${conflictInfo.branchName}`,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: resolveModelString('opus'),
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: conflictInfo.branchName,
|
||||
workMode: 'custom' as const,
|
||||
priority: 1,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
// Handler called when the user selects "Resolve with AI" from the branch conflict dialog.
|
||||
// Creates and starts the AI-assisted conflict resolution feature task.
|
||||
const handleBranchConflictResolveWithAI = useCallback(
|
||||
async (conflictData: BranchConflictData) => {
|
||||
if (conflictData.type === 'branch-switch') {
|
||||
const conflictInfo = conflictData.info;
|
||||
const description = `Resolve merge conflicts that occurred when switching from "${conflictInfo.previousBranch}" to "${conflictInfo.branchName}". Local changes were stashed before switching and reapplying them caused conflicts. Please resolve all merge conflicts, ensure the code compiles and tests pass.`;
|
||||
|
||||
await handleAddAndStartFeature(featureData);
|
||||
const featureData = {
|
||||
title: `Resolve Stash Conflicts: switch to ${conflictInfo.branchName}`,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: resolveModelString('opus'),
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: conflictInfo.branchName,
|
||||
workMode: 'custom' as const,
|
||||
priority: 1,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
await handleAddAndStartFeature(featureData);
|
||||
} else {
|
||||
const conflictInfo = conflictData.info;
|
||||
const description =
|
||||
`Resolve merge conflicts that occurred when attempting to switch to branch "${conflictInfo.branchName}". ` +
|
||||
`The checkout failed and, while restoring the previously stashed local changes, git reported merge conflicts. ` +
|
||||
`${conflictInfo.stashPopConflictMessage} ` +
|
||||
`Please review all conflicted files, resolve the conflicts, ensure the code compiles and tests pass, ` +
|
||||
`then re-attempt the branch switch.`;
|
||||
|
||||
const featureData = {
|
||||
title: `Resolve Stash-Pop Conflicts: branch switch to ${conflictInfo.branchName}`,
|
||||
category: 'Maintenance',
|
||||
description,
|
||||
images: [],
|
||||
imagePaths: [],
|
||||
skipTests: defaultSkipTests,
|
||||
model: resolveModelString('opus'),
|
||||
thinkingLevel: 'none' as const,
|
||||
branchName: conflictInfo.branchName,
|
||||
workMode: 'custom' as const,
|
||||
priority: 1,
|
||||
planningMode: 'skip' as const,
|
||||
requirePlanApproval: false,
|
||||
};
|
||||
|
||||
await handleAddAndStartFeature(featureData);
|
||||
}
|
||||
},
|
||||
[handleAddAndStartFeature, defaultSkipTests]
|
||||
);
|
||||
@@ -1925,10 +1915,17 @@ export function BoardView() {
|
||||
open={showMergeRebaseDialog}
|
||||
onOpenChange={setShowMergeRebaseDialog}
|
||||
worktree={selectedWorktreeForAction}
|
||||
onConfirm={handleConfirmResolveConflicts}
|
||||
onCreateConflictResolutionFeature={handleCreateMergeConflictResolutionFeature}
|
||||
/>
|
||||
|
||||
{/* Branch Switch / Stash Pop Conflict Dialog */}
|
||||
<BranchConflictDialog
|
||||
open={showBranchConflictDialog}
|
||||
onOpenChange={setShowBranchConflictDialog}
|
||||
conflictData={branchConflictData}
|
||||
onResolveWithAI={handleBranchConflictResolveWithAI}
|
||||
/>
|
||||
|
||||
{/* Commit Worktree Dialog */}
|
||||
<CommitWorktreeDialog
|
||||
open={showCommitWorktreeDialog}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -197,8 +197,43 @@ export function OpencodeModelConfiguration({
|
||||
onDynamicModelToggle,
|
||||
isLoadingDynamicModels = false,
|
||||
}: OpencodeModelConfigurationProps) {
|
||||
// Determine the free tier models to display.
|
||||
// When dynamic models are available from CLI, use the opencode provider models
|
||||
// from the dynamic list (they reflect the actual currently-available models).
|
||||
// Fall back to the hardcoded OPENCODE_MODELS only when CLI hasn't returned data.
|
||||
const dynamicOpencodeFreeModels = useMemo(() => {
|
||||
const opencodeModelsFromCli = dynamicModels.filter((m) => m.provider === 'opencode');
|
||||
if (opencodeModelsFromCli.length === 0) return null;
|
||||
|
||||
// Convert dynamic ModelDefinition to OpencodeModelConfig for the static section
|
||||
return opencodeModelsFromCli.map(
|
||||
(m): OpencodeModelConfig => ({
|
||||
id: m.id.replace('opencode/', 'opencode-') as OpencodeModelId,
|
||||
label: m.name.replace(/\s*\(Free\)\s*$/, '').replace(/\s*\(OpenCode\)\s*$/, ''),
|
||||
description: m.description,
|
||||
supportsVision: m.supportsVision ?? false,
|
||||
provider: 'opencode' as OpencodeProvider,
|
||||
tier: 'free',
|
||||
})
|
||||
);
|
||||
}, [dynamicModels]);
|
||||
|
||||
// Use dynamically discovered free tier models when available, otherwise hardcoded fallback
|
||||
const effectiveStaticModels = dynamicOpencodeFreeModels ?? OPENCODE_MODELS;
|
||||
|
||||
// Build an effective config map that includes dynamic models (for default model dropdown lookup)
|
||||
const effectiveModelConfigMap = useMemo(() => {
|
||||
const map = { ...OPENCODE_MODEL_CONFIG_MAP };
|
||||
if (dynamicOpencodeFreeModels) {
|
||||
for (const model of dynamicOpencodeFreeModels) {
|
||||
map[model.id] = model;
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [dynamicOpencodeFreeModels]);
|
||||
|
||||
// Group static models by provider for organized display
|
||||
const modelsByProvider = OPENCODE_MODELS.reduce(
|
||||
const modelsByProvider = effectiveStaticModels.reduce(
|
||||
(acc, model) => {
|
||||
if (!acc[model.provider]) {
|
||||
acc[model.provider] = [];
|
||||
@@ -217,7 +252,7 @@ export function OpencodeModelConfiguration({
|
||||
const [dynamicProviderSearch, setDynamicProviderSearch] = useState('');
|
||||
const normalizedDynamicSearch = dynamicProviderSearch.trim().toLowerCase();
|
||||
const hasDynamicSearch = normalizedDynamicSearch.length > 0;
|
||||
const allStaticModelIds = OPENCODE_MODELS.map((model) => model.id);
|
||||
const allStaticModelIds = effectiveStaticModels.map((model) => model.id);
|
||||
const selectableStaticModelIds = allStaticModelIds.filter(
|
||||
(modelId) => modelId !== opencodeDefaultModel
|
||||
);
|
||||
@@ -378,7 +413,7 @@ export function OpencodeModelConfiguration({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{enabledOpencodeModels.map((modelId) => {
|
||||
const model = OPENCODE_MODEL_CONFIG_MAP[modelId];
|
||||
const model = effectiveModelConfigMap[modelId];
|
||||
if (!model) return null;
|
||||
const ModelIconComponent = getModelIcon(modelId);
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user