refactor: Improve all git operations, add stash support, add improved pull request flow, add worktree file copy options, address code review comments, add cherry pick options

This commit is contained in:
gsxdsm
2026-02-17 22:02:58 -08:00
parent f4e87d4c25
commit 9af63bc1ef
89 changed files with 6811 additions and 351 deletions

View File

@@ -49,12 +49,13 @@ import {
ArchiveAllVerifiedDialog,
DeleteCompletedFeatureDialog,
DependencyLinkDialog,
DuplicateCountDialog,
EditFeatureDialog,
FollowUpDialog,
PlanApprovalDialog,
PullResolveConflictsDialog,
MergeRebaseDialog,
} from './board-view/dialogs';
import type { DependencyLinkType } from './board-view/dialogs';
import type { DependencyLinkType, PullStrategy } 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';
@@ -170,13 +171,16 @@ export function BoardView() {
// State for spawn task mode
const [spawnParentFeature, setSpawnParentFeature] = useState<Feature | null>(null);
// State for duplicate as child multiple times dialog
const [duplicateMultipleFeature, setDuplicateMultipleFeature] = useState<Feature | null>(null);
// Worktree dialog states
const [showCreateWorktreeDialog, setShowCreateWorktreeDialog] = useState(false);
const [showDeleteWorktreeDialog, setShowDeleteWorktreeDialog] = useState(false);
const [showCommitWorktreeDialog, setShowCommitWorktreeDialog] = useState(false);
const [showCreatePRDialog, setShowCreatePRDialog] = useState(false);
const [showCreateBranchDialog, setShowCreateBranchDialog] = useState(false);
const [showPullResolveConflictsDialog, setShowPullResolveConflictsDialog] = useState(false);
const [showMergeRebaseDialog, setShowMergeRebaseDialog] = useState(false);
const [selectedWorktreeForAction, setSelectedWorktreeForAction] = useState<WorktreeInfo | null>(
null
);
@@ -596,6 +600,7 @@ export function BoardView() {
handleStartNextFeatures,
handleArchiveAllVerified,
handleDuplicateFeature,
handleDuplicateAsChildMultiple,
} = useBoardActions({
currentProject,
features: hookFeatures,
@@ -917,17 +922,25 @@ export function BoardView() {
// Handler for resolving conflicts - opens dialog to select remote branch, then creates a feature
const handleResolveConflicts = useCallback((worktree: WorktreeInfo) => {
setSelectedWorktreeForAction(worktree);
setShowPullResolveConflictsDialog(true);
setShowMergeRebaseDialog(true);
}, []);
// Handler called when user confirms the pull & resolve conflicts dialog
// Handler called when user confirms the merge & rebase dialog
const handleConfirmResolveConflicts = useCallback(
async (worktree: WorktreeInfo, remoteBranch: string) => {
const description = `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.`;
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}`;
// Create the feature
const featureData = {
title: `Resolve Merge Conflicts: ${remoteBranch}${worktree.branch}`,
title,
category: 'Maintenance',
description,
images: [],
@@ -1562,6 +1575,7 @@ export function BoardView() {
},
onDuplicate: (feature) => handleDuplicateFeature(feature, false),
onDuplicateAsChild: (feature) => handleDuplicateFeature(feature, true),
onDuplicateAsChildMultiple: (feature) => setDuplicateMultipleFeature(feature),
}}
runningAutoTasks={runningAutoTasksAllWorktrees}
pipelineConfig={pipelineConfig}
@@ -1603,6 +1617,7 @@ export function BoardView() {
}}
onDuplicate={(feature) => handleDuplicateFeature(feature, false)}
onDuplicateAsChild={(feature) => handleDuplicateFeature(feature, true)}
onDuplicateAsChildMultiple={(feature) => setDuplicateMultipleFeature(feature)}
featuresWithContext={featuresWithContext}
runningAutoTasks={runningAutoTasksAllWorktrees}
onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)}
@@ -1752,6 +1767,21 @@ export function BoardView() {
branchName={outputFeature?.branchName}
/>
{/* Duplicate as Child Multiple Times Dialog */}
<DuplicateCountDialog
open={duplicateMultipleFeature !== null}
onOpenChange={(open) => {
if (!open) setDuplicateMultipleFeature(null);
}}
onConfirm={async (count) => {
if (duplicateMultipleFeature) {
await handleDuplicateAsChildMultiple(duplicateMultipleFeature, count);
setDuplicateMultipleFeature(null);
}
}}
featureTitle={duplicateMultipleFeature?.title || duplicateMultipleFeature?.description}
/>
{/* Archive All Verified Dialog */}
<ArchiveAllVerifiedDialog
open={showArchiveAllVerifiedDialog}
@@ -1899,10 +1929,10 @@ export function BoardView() {
}}
/>
{/* Pull & Resolve Conflicts Dialog */}
<PullResolveConflictsDialog
open={showPullResolveConflictsDialog}
onOpenChange={setShowPullResolveConflictsDialog}
{/* Merge & Rebase Dialog */}
<MergeRebaseDialog
open={showMergeRebaseDialog}
onOpenChange={setShowMergeRebaseDialog}
worktree={selectedWorktreeForAction}
onConfirm={handleConfirmResolveConflicts}
/>

View File

@@ -48,7 +48,7 @@ interface AgentInfoPanelProps {
projectPath: string;
contextContent?: string;
summary?: string;
isCurrentAutoTask?: boolean;
isActivelyRunning?: boolean;
}
export const AgentInfoPanel = memo(function AgentInfoPanel({
@@ -56,7 +56,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
projectPath,
contextContent,
summary,
isCurrentAutoTask,
isActivelyRunning,
}: AgentInfoPanelProps) {
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
const [isTodosExpanded, setIsTodosExpanded] = useState(false);
@@ -107,7 +107,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
// - If not receiving WebSocket events but in_progress: use normal interval (3s)
// - Otherwise: no polling
const pollingInterval = useMemo((): number | false => {
if (!(isCurrentAutoTask || feature.status === 'in_progress')) {
if (!(isActivelyRunning || feature.status === 'in_progress')) {
return false;
}
// If receiving WebSocket events, use longer polling interval as fallback
@@ -116,7 +116,7 @@ export const AgentInfoPanel = memo(function AgentInfoPanel({
}
// Default polling interval
return 3000;
}, [isCurrentAutoTask, feature.status, isReceivingWsEvents]);
}, [isActivelyRunning, feature.status, isReceivingWsEvents]);
// Fetch fresh feature data for planSpec (store data can be stale for task progress)
const { data: freshFeature } = useFeature(projectPath, feature.id, {

View File

@@ -23,6 +23,7 @@ import {
ChevronUp,
GitFork,
Copy,
Repeat,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { CountUpTimer } from '@/components/ui/count-up-timer';
@@ -33,9 +34,11 @@ import { getProviderIconForModel } from '@/components/ui/provider-icon';
function DuplicateMenuItems({
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
}: {
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
}) {
if (!onDuplicate) return null;
@@ -55,25 +58,23 @@ function DuplicateMenuItems({
);
}
// When sub-child action is available, render a proper DropdownMenuSub with
// DropdownMenuSubTrigger and DropdownMenuSubContent per Radix conventions
// Split-button pattern: main click duplicates immediately, disclosure arrow shows submenu
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="text-xs">
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<div className="flex items-center">
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicate();
}}
className="text-xs"
className="flex-1 pr-0 rounded-r-none text-xs"
>
<Copy className="w-3 h-3 mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuSubTrigger className="px-1 rounded-l-none border-l border-border/30 h-8 text-xs" />
</div>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
@@ -84,6 +85,18 @@ function DuplicateMenuItems({
<GitFork className="w-3 h-3 mr-2" />
Duplicate as Child
</DropdownMenuItem>
{onDuplicateAsChildMultiple && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDuplicateAsChildMultiple();
}}
className="text-xs"
>
<Repeat className="w-3 h-3 mr-2" />
Duplicate as Child ×N
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
@@ -100,6 +113,7 @@ interface CardHeaderProps {
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
dragHandleListeners?: DraggableSyntheticListeners;
dragHandleAttributes?: DraggableAttributes;
}
@@ -115,6 +129,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
dragHandleListeners,
dragHandleAttributes,
}: CardHeaderProps) {
@@ -183,6 +198,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
{/* Model info in dropdown */}
{(() => {
@@ -251,6 +267,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
</DropdownMenuContent>
</DropdownMenu>
@@ -343,6 +360,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
</DropdownMenuContent>
</DropdownMenu>
@@ -417,6 +435,7 @@ export const CardHeaderSection = memo(function CardHeaderSection({
<DuplicateMenuItems
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
/>
{/* Model info in dropdown */}
{(() => {

View File

@@ -54,6 +54,7 @@ interface KanbanCardProps {
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
hasContext?: boolean;
isCurrentAutoTask?: boolean;
shortcutKey?: string;
@@ -90,6 +91,7 @@ export const KanbanCard = memo(function KanbanCard({
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
hasContext,
isCurrentAutoTask,
shortcutKey,
@@ -266,6 +268,7 @@ export const KanbanCard = memo(function KanbanCard({
onSpawnTask={onSpawnTask}
onDuplicate={onDuplicate}
onDuplicateAsChild={onDuplicateAsChild}
onDuplicateAsChildMultiple={onDuplicateAsChildMultiple}
dragHandleListeners={isDraggable ? listeners : undefined}
dragHandleAttributes={isDraggable ? attributes : undefined}
/>
@@ -280,7 +283,7 @@ export const KanbanCard = memo(function KanbanCard({
projectPath={currentProject?.path ?? ''}
contextContent={contextContent}
summary={summary}
isCurrentAutoTask={isCurrentAutoTask}
isActivelyRunning={isActivelyRunning}
/>
{/* Actions */}

View File

@@ -45,6 +45,7 @@ export interface ListViewActionHandlers {
onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature) => void;
onDuplicateAsChild?: (feature: Feature) => void;
onDuplicateAsChildMultiple?: (feature: Feature) => void;
}
export interface ListViewProps {
@@ -332,6 +333,12 @@ export const ListView = memo(function ListView({
if (f) actionHandlers.onDuplicateAsChild?.(f);
}
: undefined,
duplicateAsChildMultiple: actionHandlers.onDuplicateAsChildMultiple
? (id) => {
const f = allFeatures.find((f) => f.id === id);
if (f) actionHandlers.onDuplicateAsChildMultiple?.(f);
}
: undefined,
});
},
[actionHandlers, allFeatures]

View File

@@ -15,6 +15,7 @@ import {
GitFork,
ExternalLink,
Copy,
Repeat,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -49,6 +50,7 @@ export interface RowActionHandlers {
onSpawnTask?: () => void;
onDuplicate?: () => void;
onDuplicateAsChild?: () => void;
onDuplicateAsChildMultiple?: () => void;
}
export interface RowActionsProps {
@@ -443,6 +445,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -565,6 +574,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -636,6 +652,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -712,6 +735,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -764,6 +794,13 @@ export const RowActions = memo(function RowActions({
label="Duplicate as Child"
onClick={withClose(handlers.onDuplicateAsChild)}
/>
{handlers.onDuplicateAsChildMultiple && (
<MenuItem
icon={Repeat}
label="Duplicate as Child ×N"
onClick={withClose(handlers.onDuplicateAsChildMultiple)}
/>
)}
</DropdownMenuSubContent>
)}
</DropdownMenuSub>
@@ -804,6 +841,7 @@ export function createRowActionHandlers(
spawnTask?: (id: string) => void;
duplicate?: (id: string) => void;
duplicateAsChild?: (id: string) => void;
duplicateAsChildMultiple?: (id: string) => void;
}
): RowActionHandlers {
return {
@@ -824,5 +862,8 @@ export function createRowActionHandlers(
onDuplicateAsChild: actions.duplicateAsChild
? () => actions.duplicateAsChild!(featureId)
: undefined,
onDuplicateAsChildMultiple: actions.duplicateAsChildMultiple
? () => actions.duplicateAsChildMultiple!(featureId)
: undefined,
};
}

View File

@@ -0,0 +1,757 @@
import { useState, useEffect, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
GitCommit,
AlertTriangle,
Wrench,
User,
Clock,
Copy,
Check,
Cherry,
ChevronDown,
ChevronRight,
FileText,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { WorktreeInfo, MergeConflictInfo } from '../worktree-panel/types';
export interface CherryPickConflictInfo {
commitHashes: string[];
targetBranch: string;
targetWorktreePath: string;
}
interface RemoteInfo {
name: string;
url: string;
branches: Array<{
name: string;
fullRef: string;
}>;
}
interface CommitInfo {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
interface CherryPickDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onCherryPicked: () => void;
onCreateConflictResolutionFeature?: (conflictInfo: MergeConflictInfo) => void;
}
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 5) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
return date.toLocaleDateString();
}
function CopyHashButton({ hash }: { hash: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(hash);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy hash');
}
};
return (
<button
onClick={handleCopy}
className="inline-flex items-center gap-1 font-mono text-[11px] bg-muted hover:bg-muted/80 px-1.5 py-0.5 rounded cursor-pointer transition-colors"
title={`Copy full hash: ${hash}`}
>
{copied ? (
<Check className="w-2.5 h-2.5 text-green-500" />
) : (
<Copy className="w-2.5 h-2.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">{hash.slice(0, 7)}</span>
</button>
);
}
type Step = 'select-branch' | 'select-commits' | 'conflict';
export function CherryPickDialog({
open,
onOpenChange,
worktree,
onCherryPicked,
onCreateConflictResolutionFeature,
}: CherryPickDialogProps) {
// Step management
const [step, setStep] = useState<Step>('select-branch');
// Branch selection state
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [localBranches, setLocalBranches] = useState<string[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [selectedBranch, setSelectedBranch] = useState<string>('');
const [loadingBranches, setLoadingBranches] = useState(false);
// Commits state
const [commits, setCommits] = useState<CommitInfo[]>([]);
const [selectedCommitHashes, setSelectedCommitHashes] = useState<Set<string>>(new Set());
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
const [loadingCommits, setLoadingCommits] = useState(false);
const [loadingMoreCommits, setLoadingMoreCommits] = useState(false);
const [commitsError, setCommitsError] = useState<string | null>(null);
const [commitLimit, setCommitLimit] = useState(30);
const [hasMoreCommits, setHasMoreCommits] = useState(false);
// Cherry-pick state
const [isCherryPicking, setIsCherryPicking] = useState(false);
// Conflict state
const [conflictInfo, setConflictInfo] = useState<CherryPickConflictInfo | null>(null);
// All available branch options for the current remote selection
const branchOptions =
selectedRemote === '__local__'
? localBranches.filter((b) => b !== worktree?.branch)
: (remotes.find((r) => r.name === selectedRemote)?.branches || []).map((b) => b.fullRef);
// Reset state when dialog opens
useEffect(() => {
if (open) {
setStep('select-branch');
setSelectedRemote('');
setSelectedBranch('');
setCommits([]);
setSelectedCommitHashes(new Set());
setExpandedCommits(new Set());
setConflictInfo(null);
setCommitsError(null);
setCommitLimit(30);
setHasMoreCommits(false);
}
}, [open]);
// Fetch remotes and local branches when dialog opens
useEffect(() => {
if (!open || !worktree) return;
const fetchBranchData = async () => {
setLoadingBranches(true);
try {
const api = getHttpApiClient();
// Fetch remotes and local branches in parallel
const [remotesResult, branchesResult] = await Promise.all([
api.worktree.listRemotes(worktree.path),
api.worktree.listBranches(worktree.path, false),
]);
if (remotesResult.success && remotesResult.result) {
setRemotes(remotesResult.result.remotes);
// Default to first remote if available, otherwise local
if (remotesResult.result.remotes.length > 0) {
setSelectedRemote(remotesResult.result.remotes[0].name);
} else {
setSelectedRemote('__local__');
}
}
if (branchesResult.success && branchesResult.result) {
const branches = branchesResult.result.branches
.filter(
(b: { isRemote: boolean; name: string }) => !b.isRemote && b.name !== worktree.branch
)
.map((b: { name: string }) => b.name);
setLocalBranches(branches);
}
} catch (err) {
console.error('Failed to fetch branch data:', err);
} finally {
setLoadingBranches(false);
}
};
fetchBranchData();
}, [open, worktree]);
// Fetch commits when branch is selected
const fetchCommits = useCallback(
async (limit: number = 30, append: boolean = false) => {
if (!worktree || !selectedBranch) return;
if (append) {
setLoadingMoreCommits(true);
} else {
setLoadingCommits(true);
setCommitsError(null);
setCommits([]);
setSelectedCommitHashes(new Set());
}
try {
const api = getHttpApiClient();
const result = await api.worktree.getBranchCommitLog(worktree.path, selectedBranch, limit);
if (result.success && result.result) {
setCommits(result.result.commits);
// If we got exactly the limit, there may be more commits
setHasMoreCommits(result.result.commits.length >= limit);
} else {
setCommitsError(result.error || 'Failed to load commits');
}
} catch (err) {
setCommitsError(err instanceof Error ? err.message : 'Failed to load commits');
} finally {
setLoadingCommits(false);
setLoadingMoreCommits(false);
}
},
[worktree, selectedBranch]
);
// Handle proceeding from branch selection to commit selection
const handleProceedToCommits = useCallback(() => {
if (!selectedBranch) return;
setStep('select-commits');
fetchCommits(commitLimit);
}, [selectedBranch, fetchCommits, commitLimit]);
// Handle loading more commits
const handleLoadMore = useCallback(() => {
const newLimit = Math.min(commitLimit + 30, 100);
setCommitLimit(newLimit);
fetchCommits(newLimit, true);
}, [commitLimit, fetchCommits]);
// Toggle commit selection
const toggleCommitSelection = useCallback((hash: string) => {
setSelectedCommitHashes((prev) => {
const next = new Set(prev);
if (next.has(hash)) {
next.delete(hash);
} else {
next.add(hash);
}
return next;
});
}, []);
// Toggle commit file list expansion
const toggleCommitExpanded = useCallback((hash: string, e: React.MouseEvent) => {
e.stopPropagation();
setExpandedCommits((prev) => {
const next = new Set(prev);
if (next.has(hash)) {
next.delete(hash);
} else {
next.add(hash);
}
return next;
});
}, []);
// Handle cherry-pick execution
const handleCherryPick = useCallback(async () => {
if (!worktree || selectedCommitHashes.size === 0) return;
setIsCherryPicking(true);
try {
const api = getHttpApiClient();
// Order commits from oldest to newest (reverse of display order)
// so they're applied in chronological order
const orderedHashes = commits
.filter((c) => selectedCommitHashes.has(c.hash))
.reverse()
.map((c) => c.hash);
const result = await api.worktree.cherryPick(worktree.path, orderedHashes);
if (result.success) {
toast.success(`Cherry-picked ${orderedHashes.length} commit(s)`, {
description: `Successfully applied to ${worktree.branch}`,
});
onCherryPicked();
onOpenChange(false);
} else {
// Check for conflicts
const errorMessage = result.error || '';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
(result as { hasConflicts?: boolean }).hasConflicts;
if (hasConflicts && onCreateConflictResolutionFeature) {
setConflictInfo({
commitHashes: orderedHashes,
targetBranch: worktree.branch,
targetWorktreePath: worktree.path,
});
setStep('conflict');
toast.error('Cherry-pick conflicts detected', {
description: 'The cherry-pick has conflicts that need to be resolved.',
});
} else {
toast.error('Cherry-pick failed', {
description: result.error,
});
}
}
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
const hasConflicts =
errorMessage.toLowerCase().includes('conflict') ||
errorMessage.toLowerCase().includes('cherry-pick failed');
if (hasConflicts && onCreateConflictResolutionFeature) {
const orderedHashes = commits
.filter((c) => selectedCommitHashes.has(c.hash))
.reverse()
.map((c) => c.hash);
setConflictInfo({
commitHashes: orderedHashes,
targetBranch: worktree.branch,
targetWorktreePath: worktree.path,
});
setStep('conflict');
toast.error('Cherry-pick conflicts detected', {
description: 'The cherry-pick has conflicts that need to be resolved.',
});
} else {
toast.error('Cherry-pick failed', {
description: errorMessage,
});
}
} finally {
setIsCherryPicking(false);
}
}, [
worktree,
selectedCommitHashes,
commits,
onCherryPicked,
onOpenChange,
onCreateConflictResolutionFeature,
]);
// Handle creating a conflict resolution feature
const handleCreateConflictResolutionFeature = useCallback(() => {
if (conflictInfo && onCreateConflictResolutionFeature) {
onCreateConflictResolutionFeature({
sourceBranch: selectedBranch,
targetBranch: conflictInfo.targetBranch,
targetWorktreePath: conflictInfo.targetWorktreePath,
});
onOpenChange(false);
}
}, [conflictInfo, selectedBranch, onCreateConflictResolutionFeature, onOpenChange]);
if (!worktree) return null;
// Conflict resolution UI
if (step === 'conflict' && conflictInfo) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Cherry-Pick Conflicts Detected
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
There are conflicts when cherry-picking commits from{' '}
<code className="font-mono bg-muted px-1 rounded">{selectedBranch}</code> into{' '}
<code className="font-mono bg-muted px-1 rounded">
{conflictInfo.targetBranch}
</code>
.
</span>
<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">
The cherry-pick could not be completed automatically. You can create a feature
task to resolve the conflicts in the{' '}
<code className="font-mono bg-muted px-0.5 rounded">
{conflictInfo.targetBranch}
</code>{' '}
branch.
</span>
</div>
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a high-priority feature task that will:
</p>
<ul className="text-sm text-muted-foreground mt-2 list-disc list-inside space-y-1">
<li>
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>Ensure the code compiles and tests pass</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => setStep('select-commits')}>
Back
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
onClick={handleCreateConflictResolutionFeature}
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<Wrench className="w-4 h-4 mr-2" />
Create Resolve Conflicts Feature
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Step 2: Select commits
if (step === 'select-commits') {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-black dark:text-black" />
Cherry Pick Commits
</DialogTitle>
<DialogDescription>
Select commits from{' '}
<code className="font-mono bg-muted px-1 rounded">{selectedBranch}</code> to apply to{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
{loadingCommits && (
<div className="flex items-center justify-center py-12">
<Spinner size="md" />
<span className="ml-2 text-sm text-muted-foreground">Loading commits...</span>
</div>
)}
{commitsError && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-destructive">{commitsError}</p>
</div>
)}
{!loadingCommits && !commitsError && commits.length === 0 && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No commits found on this branch</p>
</div>
)}
{!loadingCommits && !commitsError && commits.length > 0 && (
<div className="space-y-0.5 mt-2">
{commits.map((commit, index) => {
const isSelected = selectedCommitHashes.has(commit.hash);
const isExpanded = expandedCommits.has(commit.hash);
const hasFiles = commit.files && commit.files.length > 0;
return (
<div
key={commit.hash}
className={cn(
'group relative rounded-md transition-colors',
isSelected
? 'bg-primary/10 border border-primary/30'
: 'border border-transparent',
index === 0 && !isSelected && 'bg-muted/30'
)}
>
<div
onClick={() => toggleCommitSelection(commit.hash)}
className={cn(
'flex gap-3 py-2.5 px-3 cursor-pointer rounded-md transition-colors',
!isSelected && 'hover:bg-muted/50'
)}
>
{/* Checkbox */}
<div className="flex items-start pt-1 shrink-0">
<Checkbox
checked={isSelected}
onCheckedChange={() => toggleCommitSelection(commit.hash)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
/>
</div>
{/* Commit content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug break-words">
{commit.subject}
</p>
<CopyHashButton hash={commit.hash} />
</div>
{commit.body && (
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words line-clamp-2">
{commit.body}
</p>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<User className="w-3 h-3" />
{commit.author}
</span>
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
<time
dateTime={commit.date}
title={new Date(commit.date).toLocaleString()}
>
{formatRelativeDate(commit.date)}
</time>
</span>
{hasFiles && (
<button
onClick={(e) => toggleCommitExpanded(commit.hash, e)}
className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<FileText className="w-3 h-3" />
{commit.files.length} file{commit.files.length !== 1 ? 's' : ''}
</button>
)}
</div>
</div>
</div>
{/* Expanded file list */}
{isExpanded && hasFiles && (
<div className="border-t mx-3 px-3 py-2 bg-muted/30 rounded-b-md ml-8">
<div className="space-y-0.5">
{commit.files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
>
<FileText className="w-3 h-3 shrink-0" />
<span className="font-mono break-all">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
{/* Load More button */}
{hasMoreCommits && commitLimit < 100 && (
<div className="flex justify-center pt-3 pb-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleLoadMore();
}}
disabled={loadingMoreCommits}
className="text-xs text-muted-foreground hover:text-foreground"
>
{loadingMoreCommits ? (
<>
<Spinner size="sm" className="mr-2" />
Loading...
</>
) : (
<>
<ChevronDown className="w-3.5 h-3.5 mr-1.5" />
Load More Commits
</>
)}
</Button>
</div>
)}
</div>
)}
</div>
</div>
<DialogFooter className="mt-4 pt-4 border-t">
<Button
variant="ghost"
onClick={() => {
setStep('select-branch');
setSelectedBranch('');
}}
>
Back
</Button>
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isCherryPicking}>
Cancel
</Button>
<Button
onClick={handleCherryPick}
disabled={selectedCommitHashes.size === 0 || isCherryPicking}
>
{isCherryPicking ? (
<>
<Spinner size="sm" variant="foreground" className="mr-2" />
Cherry Picking...
</>
) : (
<>
<Cherry className="w-4 h-4 mr-2" />
Cherry Pick
{selectedCommitHashes.size > 0 ? ` (${selectedCommitHashes.size})` : ''}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Step 1: Select branch (and optionally remote)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cherry className="w-5 h-5 text-black dark:text-black" />
Cherry Pick
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-4">
<span className="block">
Select a branch to cherry-pick commits from into{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</span>
{loadingBranches ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner size="sm" />
Loading branches...
</div>
) : (
<>
{/* Remote selector - only show if there are remotes */}
{remotes.length > 0 && (
<div className="space-y-2">
<Label className="text-sm text-foreground">Source</Label>
<Select
value={selectedRemote}
onValueChange={(value) => {
setSelectedRemote(value);
setSelectedBranch('');
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select source..." />
</SelectTrigger>
<SelectContent className="text-black dark:text-black">
<SelectItem value="__local__">Local Branches</SelectItem>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
{remote.name} ({remote.url})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* Branch selector */}
<div className="space-y-2">
<Label className="text-sm text-foreground">Branch</Label>
{branchOptions.length === 0 ? (
<p className="text-sm text-muted-foreground">No other branches available</p>
) : (
<Select value={selectedBranch} onValueChange={setSelectedBranch}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a branch..." />
</SelectTrigger>
<SelectContent className="text-black dark:text-black">
{branchOptions.map((branch) => (
<SelectItem key={branch} value={branch}>
{branch}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleProceedToCommits} disabled={!selectedBranch || loadingBranches}>
<GitCommit className="w-4 h-4 mr-2" />
View Commits
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -307,6 +307,8 @@ export function CommitWorktreeDialog({
setSelectedFiles(new Set());
setExpandedFile(null);
let cancelled = false;
const loadDiffs = async () => {
try {
const api = getElectronAPI();
@@ -314,20 +316,24 @@ export function CommitWorktreeDialog({
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
const fileList = result.files ?? [];
setFiles(fileList);
setDiffContent(result.diff ?? '');
if (!cancelled) setFiles(fileList);
if (!cancelled) setDiffContent(result.diff ?? '');
// Select all files by default
setSelectedFiles(new Set(fileList.map((f) => f.path)));
if (!cancelled) setSelectedFiles(new Set(fileList.map((f) => f.path)));
}
}
} catch (err) {
console.warn('Failed to load diffs for commit dialog:', err);
} finally {
setIsLoadingDiffs(false);
if (!cancelled) setIsLoadingDiffs(false);
}
};
loadDiffs();
return () => {
cancelled = true;
};
}
}, [open, worktree]);

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
@@ -11,9 +11,20 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitBranchPlus } from 'lucide-react';
import { GitBranchPlus, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
interface WorktreeInfo {
@@ -24,6 +35,12 @@ interface WorktreeInfo {
changedFilesCount?: number;
}
interface BranchInfo {
name: string;
isCurrent: boolean;
isRemote: boolean;
}
const logger = createLogger('CreateBranchDialog');
interface CreateBranchDialogProps {
@@ -40,16 +57,45 @@ export function CreateBranchDialog({
onCreated,
}: CreateBranchDialogProps) {
const [branchName, setBranchName] = useState('');
const [baseBranch, setBaseBranch] = useState('');
const [branches, setBranches] = useState<BranchInfo[]>([]);
const [isLoadingBranches, setIsLoadingBranches] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset state when dialog opens/closes
const fetchBranches = useCallback(async () => {
if (!worktree) return;
setIsLoadingBranches(true);
try {
const api = getHttpApiClient();
const result = await api.worktree.listBranches(worktree.path, true);
if (result.success && result.result) {
setBranches(result.result.branches);
// Default to current branch
if (result.result.currentBranch) {
setBaseBranch(result.result.currentBranch);
}
}
} catch (err) {
logger.error('Failed to fetch branches:', err);
} finally {
setIsLoadingBranches(false);
}
}, [worktree]);
// Reset state and fetch branches when dialog opens
useEffect(() => {
if (open) {
setBranchName('');
setBaseBranch('');
setError(null);
setBranches([]);
fetchBranches();
}
}, [open]);
}, [open, fetchBranches]);
const handleCreate = async () => {
if (!worktree || !branchName.trim()) return;
@@ -71,7 +117,13 @@ export function CreateBranchDialog({
return;
}
const result = await api.worktree.checkoutBranch(worktree.path, branchName.trim());
// Pass baseBranch if user selected one different from the current branch
const selectedBase = baseBranch || undefined;
const result = await api.worktree.checkoutBranch(
worktree.path,
branchName.trim(),
selectedBase
);
if (result.success && result.result) {
toast.success(result.result.message);
@@ -88,6 +140,10 @@ export function CreateBranchDialog({
}
};
// Separate local and remote branches
const localBranches = branches.filter((b) => !b.isRemote);
const remoteBranches = branches.filter((b) => b.isRemote);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
@@ -96,12 +152,7 @@ export function CreateBranchDialog({
<GitBranchPlus className="w-5 h-5" />
Create New Branch
</DialogTitle>
<DialogDescription>
Create a new branch from{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
</DialogDescription>
<DialogDescription>Create a new branch from a base branch</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
@@ -123,8 +174,74 @@ export function CreateBranchDialog({
disabled={isCreating}
autoFocus
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="base-branch">Base Branch</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchBranches}
disabled={isLoadingBranches || isCreating}
className="h-6 px-2 text-xs"
>
{isLoadingBranches ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
{isLoadingBranches && branches.length === 0 ? (
<div className="flex items-center justify-center py-3 border rounded-md border-input">
<Spinner size="sm" className="mr-2" />
<span className="text-sm text-muted-foreground">Loading branches...</span>
</div>
) : (
<Select value={baseBranch} onValueChange={setBaseBranch} disabled={isCreating}>
<SelectTrigger id="base-branch">
<SelectValue placeholder="Select base branch" />
</SelectTrigger>
<SelectContent>
{localBranches.length > 0 && (
<SelectGroup>
<SelectLabel>Local Branches</SelectLabel>
{localBranches.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
<span className={branch.isCurrent ? 'font-medium' : ''}>
{branch.name}
{branch.isCurrent ? ' (current)' : ''}
</span>
</SelectItem>
))}
</SelectGroup>
)}
{remoteBranches.length > 0 && (
<>
{localBranches.length > 0 && <SelectSeparator />}
<SelectGroup>
<SelectLabel>Remote Branches</SelectLabel>
{remoteBranches.map((branch) => (
<SelectItem key={branch.name} value={branch.name}>
{branch.name}
</SelectItem>
))}
</SelectGroup>
</>
)}
{localBranches.length === 0 && remoteBranches.length === 0 && (
<SelectItem value="HEAD" disabled>
No branches found
</SelectItem>
)}
</SelectContent>
</Select>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>

View File

@@ -13,12 +13,25 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { BranchAutocomplete } from '@/components/ui/branch-autocomplete';
import { GitPullRequest, ExternalLink } from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { GitPullRequest, ExternalLink, Sparkles, RefreshCw } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getElectronAPI } from '@/lib/electron';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { useWorktreeBranches } from '@/hooks/queries';
interface RemoteInfo {
name: string;
url: string;
}
interface WorktreeInfo {
path: string;
branch: string;
@@ -58,6 +71,14 @@ export function CreatePRDialog({
// Track whether an operation completed that warrants a refresh
const operationCompletedRef = useRef(false);
// Remote selection state
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [isLoadingRemotes, setIsLoadingRemotes] = useState(false);
// Generate description state
const [isGeneratingDescription, setIsGeneratingDescription] = useState(false);
// Use React Query for branch fetching - only enabled when dialog is open
const { data: branchesData, isLoading: isLoadingBranches } = useWorktreeBranches(
open ? worktree?.path : undefined,
@@ -70,6 +91,44 @@ export function CreatePRDialog({
return branchesData.branches.map((b) => b.name).filter((name) => name !== worktree?.branch);
}, [branchesData?.branches, worktree?.branch]);
// Fetch remotes when dialog opens
const fetchRemotes = useCallback(async () => {
if (!worktree) return;
setIsLoadingRemotes(true);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos: RemoteInfo[] = result.result.remotes.map(
(r: { name: string; url: string }) => ({
name: r.name,
url: r.url,
})
);
setRemotes(remoteInfos);
// Auto-select 'origin' if available, otherwise first remote
if (remoteInfos.length > 0) {
const defaultRemote = remoteInfos.find((r) => r.name === 'origin') || remoteInfos[0];
setSelectedRemote(defaultRemote.name);
}
}
} catch {
// Silently fail - remotes selector will just not show
} finally {
setIsLoadingRemotes(false);
}
}, [worktree]);
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree, fetchRemotes]);
// Common state reset function to avoid duplication
const resetState = useCallback(() => {
setTitle('');
@@ -81,6 +140,9 @@ export function CreatePRDialog({
setPrUrl(null);
setBrowserUrl(null);
setShowBrowserFallback(false);
setRemotes([]);
setSelectedRemote('');
setIsGeneratingDescription(false);
operationCompletedRef.current = false;
}, [defaultBaseBranch]);
@@ -90,6 +152,37 @@ export function CreatePRDialog({
resetState();
}, [open, worktree?.path, resetState]);
const handleGenerateDescription = async () => {
if (!worktree) return;
setIsGeneratingDescription(true);
try {
const api = getHttpApiClient();
const result = await api.worktree.generatePRDescription(worktree.path, baseBranch);
if (result.success) {
if (result.title) {
setTitle(result.title);
}
if (result.body) {
setBody(result.body);
}
toast.success('PR description generated');
} else {
toast.error('Failed to generate description', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to generate description', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsGeneratingDescription(false);
}
};
const handleCreate = async () => {
if (!worktree) return;
@@ -109,6 +202,7 @@ export function CreatePRDialog({
prBody: body || `Changes from branch ${worktree.branch}`,
baseBranch,
draft: isDraft,
remote: selectedRemote || undefined,
});
if (result.success && result.result) {
@@ -329,7 +423,33 @@ export function CreatePRDialog({
)}
<div className="grid gap-2">
<Label htmlFor="pr-title">PR Title</Label>
<div className="flex items-center justify-between">
<Label htmlFor="pr-title">PR Title</Label>
<Button
variant="ghost"
size="sm"
onClick={handleGenerateDescription}
disabled={isGeneratingDescription || isLoading}
className="h-6 px-2 text-xs"
title={
worktree.hasChanges
? 'Generate title and description from commits and uncommitted changes'
: 'Generate title and description from commits'
}
>
{isGeneratingDescription ? (
<>
<Spinner size="xs" className="mr-1" />
Generating...
</>
) : (
<>
<Sparkles className="w-3 h-3 mr-1" />
Generate with AI
</>
)}
</Button>
</div>
<Input
id="pr-title"
placeholder={worktree.branch}
@@ -350,6 +470,49 @@ export function CreatePRDialog({
</div>
<div className="flex flex-col gap-4">
{/* Remote selector - only show if multiple remotes are available */}
{remotes.length > 1 && (
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Push to Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={fetchRemotes}
disabled={isLoadingRemotes}
className="h-6 px-2 text-xs"
>
{isLoadingRemotes ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="base-branch">Base Branch</Label>
<BranchAutocomplete

View File

@@ -313,8 +313,8 @@ export function DiscardWorktreeChangesDialog({
const fileList = result.files ?? [];
setFiles(fileList);
setDiffContent(result.diff ?? '');
// Select all files by default
setSelectedFiles(new Set(fileList.map((f) => f.path)));
// No files selected by default
setSelectedFiles(new Set());
}
}
} catch (err) {

View File

@@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import { Copy } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { HotkeyButton } from '@/components/ui/hotkey-button';
import { Input } from '@/components/ui/input';
interface DuplicateCountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: (count: number) => void;
featureTitle?: string;
}
export function DuplicateCountDialog({
open,
onOpenChange,
onConfirm,
featureTitle,
}: DuplicateCountDialogProps) {
const [count, setCount] = useState(2);
// Reset count when dialog opens
useEffect(() => {
if (open) {
setCount(2);
}
}, [open]);
const handleConfirm = () => {
if (count >= 1 && count <= 50) {
onConfirm(count);
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-popover border-border max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Copy className="w-5 h-5 text-primary" />
Duplicate as Child ×N
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Creates a chain of duplicates where each is a child of the previous, so they execute
sequentially.
{featureTitle && (
<span className="block mt-1 text-xs">
Source: <span className="font-medium">{featureTitle}</span>
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="py-2">
<label htmlFor="duplicate-count" className="text-sm text-muted-foreground mb-2 block">
Number of copies
</label>
<Input
id="duplicate-count"
type="number"
min={1}
max={50}
value={count}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!isNaN(val)) {
setCount(Math.min(50, Math.max(1, val)));
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
className="w-full"
autoFocus
/>
<p className="text-xs text-muted-foreground mt-1.5">Enter a number between 1 and 50</p>
</div>
<DialogFooter className="gap-2 sm:gap-2 pt-2">
<Button variant="ghost" onClick={() => onOpenChange(false)} className="px-4">
Cancel
</Button>
<HotkeyButton
variant="default"
onClick={handleConfirm}
hotkey={{ key: 'Enter', cmdCtrl: true }}
hotkeyActive={open}
className="px-4"
disabled={count < 1 || count > 50}
>
<Copy className="w-4 h-4 mr-2" />
Create {count} {count === 1 ? 'Copy' : 'Copies'}
</HotkeyButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -5,14 +5,20 @@ export { CompletedFeaturesModal } from './completed-features-modal';
export { ArchiveAllVerifiedDialog } from './archive-all-verified-dialog';
export { DeleteCompletedFeatureDialog } from './delete-completed-feature-dialog';
export { DependencyLinkDialog, type DependencyLinkType } from './dependency-link-dialog';
export { DuplicateCountDialog } from './duplicate-count-dialog';
export { DiscardWorktreeChangesDialog } from './discard-worktree-changes-dialog';
export { EditFeatureDialog } from './edit-feature-dialog';
export { FollowUpDialog, type FollowUpHistoryEntry } from './follow-up-dialog';
export { MergeWorktreeDialog, type MergeConflictInfo } from './merge-worktree-dialog';
export { PlanApprovalDialog } from './plan-approval-dialog';
export { MassEditDialog } from './mass-edit-dialog';
export { PullResolveConflictsDialog } from './pull-resolve-conflicts-dialog';
export { MergeRebaseDialog, type PullStrategy } from './merge-rebase-dialog';
export { PushToRemoteDialog } from './push-to-remote-dialog';
export { SelectRemoteDialog, type SelectRemoteOperation } from './select-remote-dialog';
export { ViewCommitsDialog } from './view-commits-dialog';
export { ViewWorktreeChangesDialog } from './view-worktree-changes-dialog';
export { ExportFeaturesDialog } from './export-features-dialog';
export { ImportFeaturesDialog } from './import-features-dialog';
export { StashChangesDialog } from './stash-changes-dialog';
export { ViewStashesDialog } from './view-stashes-dialog';
export { CherryPickDialog } from './cherry-pick-dialog';

View File

@@ -21,10 +21,12 @@ import {
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { GitMerge, RefreshCw, AlertTriangle } from 'lucide-react';
import { GitMerge, RefreshCw, AlertTriangle, GitBranch } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
export type PullStrategy = 'merge' | 'rebase';
interface RemoteBranch {
name: string;
fullRef: string;
@@ -36,24 +38,29 @@ interface RemoteInfo {
branches: RemoteBranch[];
}
const logger = createLogger('PullResolveConflictsDialog');
const logger = createLogger('MergeRebaseDialog');
interface PullResolveConflictsDialogProps {
interface MergeRebaseDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onConfirm: (worktree: WorktreeInfo, remoteBranch: string) => void | Promise<void>;
onConfirm: (
worktree: WorktreeInfo,
remoteBranch: string,
strategy: PullStrategy
) => void | Promise<void>;
}
export function PullResolveConflictsDialog({
export function MergeRebaseDialog({
open,
onOpenChange,
worktree,
onConfirm,
}: PullResolveConflictsDialogProps) {
}: MergeRebaseDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [selectedBranch, setSelectedBranch] = useState<string>('');
const [selectedStrategy, setSelectedStrategy] = useState<PullStrategy>('merge');
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -70,6 +77,7 @@ export function PullResolveConflictsDialog({
if (!open) {
setSelectedRemote('');
setSelectedBranch('');
setSelectedStrategy('merge');
setError(null);
}
}, [open]);
@@ -161,7 +169,7 @@ export function PullResolveConflictsDialog({
const handleConfirm = () => {
if (!worktree || !selectedBranch) return;
onConfirm(worktree, selectedBranch);
onConfirm(worktree, selectedBranch, selectedStrategy);
onOpenChange(false);
};
@@ -174,10 +182,10 @@ export function PullResolveConflictsDialog({
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitMerge className="w-5 h-5 text-purple-500" />
Pull & Resolve Conflicts
Merge & Rebase
</DialogTitle>
<DialogDescription>
Select a remote branch to pull from and resolve conflicts with{' '}
Select a remote branch to merge or rebase with{' '}
<span className="font-mono text-foreground">
{worktree?.branch || 'current branch'}
</span>
@@ -225,13 +233,16 @@ export function PullResolveConflictsDialog({
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
@@ -264,13 +275,62 @@ export function PullResolveConflictsDialog({
)}
</div>
<div className="grid gap-2">
<Label htmlFor="strategy-select">Strategy</Label>
<Select
value={selectedStrategy}
onValueChange={(value) => setSelectedStrategy(value as PullStrategy)}
>
<SelectTrigger id="strategy-select">
<SelectValue placeholder="Select a strategy" />
</SelectTrigger>
<SelectContent>
<SelectItem
value="merge"
description={
<span className="text-xs text-muted-foreground">
Creates a merge commit preserving history
</span>
}
>
<span className="flex items-center gap-2">
<GitMerge className="w-3.5 h-3.5 text-purple-500" />
<span className="font-medium">Merge</span>
</span>
</SelectItem>
<SelectItem
value="rebase"
description={
<span className="text-xs text-muted-foreground">
Replays commits on top for linear history
</span>
}
>
<span className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5 text-blue-500" />
<span className="font-medium">Rebase</span>
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{selectedBranch && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
This will create a feature task to pull from{' '}
<span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{worktree?.branch}</span> and resolve
any merge conflicts.
This will create a feature task to{' '}
{selectedStrategy === 'rebase' ? (
<>
rebase <span className="font-mono text-foreground">{worktree?.branch}</span>{' '}
onto <span className="font-mono text-foreground">{selectedBranch}</span>
</>
) : (
<>
merge <span className="font-mono text-foreground">{selectedBranch}</span> into{' '}
<span className="font-mono text-foreground">{worktree?.branch}</span>
</>
)}{' '}
and resolve any conflicts.
</p>
</div>
)}
@@ -287,7 +347,7 @@ export function PullResolveConflictsDialog({
className="bg-purple-600 hover:bg-purple-700 text-white"
>
<GitMerge className="w-4 h-4 mr-2" />
Pull & Resolve
Merge & Rebase
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -306,13 +306,16 @@ export function PushToRemoteDialog({
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem key={remote.name} value={remote.name}>
<div className="flex flex-col items-start">
<span className="font-medium">{remote.name}</span>
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
</div>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>

View File

@@ -0,0 +1,264 @@
import { useState, useEffect, useCallback } from 'react';
import { createLogger } from '@automaker/utils/logger';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getHttpApiClient } from '@/lib/http-api-client';
import { getErrorMessage } from '@/lib/utils';
import { Download, Upload, RefreshCw, AlertTriangle } from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo } from '../worktree-panel/types';
interface RemoteInfo {
name: string;
url: string;
}
const logger = createLogger('SelectRemoteDialog');
export type SelectRemoteOperation = 'pull' | 'push';
interface SelectRemoteDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
operation: SelectRemoteOperation;
onConfirm: (worktree: WorktreeInfo, remote: string) => void;
}
export function SelectRemoteDialog({
open,
onOpenChange,
worktree,
operation,
onConfirm,
}: SelectRemoteDialogProps) {
const [remotes, setRemotes] = useState<RemoteInfo[]>([]);
const [selectedRemote, setSelectedRemote] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [isRefreshing, setIsRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchRemotes = useCallback(async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos = result.result.remotes.map((r: { name: string; url: string }) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
} else {
setError(result.error || 'Failed to fetch remotes');
}
} catch (err) {
logger.error('Failed to fetch remotes:', err);
setError(getErrorMessage(err));
} finally {
setIsLoading(false);
}
}, [worktree]);
// Fetch remotes when dialog opens
useEffect(() => {
if (open && worktree) {
fetchRemotes();
}
}, [open, worktree, fetchRemotes]);
// Reset state when dialog closes
useEffect(() => {
if (!open) {
setSelectedRemote('');
setError(null);
}
}, [open]);
// Auto-select default remote when remotes are loaded
useEffect(() => {
if (remotes.length > 0 && !selectedRemote) {
// Default to 'origin' if available, otherwise first remote
const defaultRemote = remotes.find((r) => r.name === 'origin') || remotes[0];
setSelectedRemote(defaultRemote.name);
}
}, [remotes, selectedRemote]);
const handleRefresh = async () => {
if (!worktree) return;
setIsRefreshing(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result) {
const remoteInfos = result.result.remotes.map((r: { name: string; url: string }) => ({
name: r.name,
url: r.url,
}));
setRemotes(remoteInfos);
} else {
setError(result.error || 'Failed to refresh remotes');
}
} catch (err) {
logger.error('Failed to refresh remotes:', err);
setError(getErrorMessage(err));
} finally {
setIsRefreshing(false);
}
};
const handleConfirm = () => {
if (!worktree || !selectedRemote) return;
onConfirm(worktree, selectedRemote);
onOpenChange(false);
};
const isPull = operation === 'pull';
const Icon = isPull ? Download : Upload;
const title = isPull ? 'Pull from Remote' : 'Push to Remote';
const actionLabel = isPull
? `Pull from ${selectedRemote || 'Remote'}`
: `Push to ${selectedRemote || 'Remote'}`;
const description = isPull ? (
<>
Select a remote to pull changes into{' '}
<span className="font-mono text-foreground">{worktree?.branch || 'current branch'}</span>
</>
) : (
<>
Select a remote to push{' '}
<span className="font-mono text-foreground">{worktree?.branch || 'current branch'}</span> to
</>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icon className="w-5 h-5 text-primary" />
{title}
</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
</div>
) : error ? (
<div className="flex flex-col items-center gap-4 py-6">
<div className="flex items-center gap-2 text-destructive">
<AlertTriangle className="w-5 h-5" />
<span className="text-sm">{error}</span>
</div>
<Button variant="outline" size="sm" onClick={fetchRemotes}>
<RefreshCw className="w-4 h-4 mr-2" />
Retry
</Button>
</div>
) : (
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="remote-select">Select Remote</Label>
<Button
variant="ghost"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
className="h-6 px-2 text-xs"
>
{isRefreshing ? (
<Spinner size="xs" className="mr-1" />
) : (
<RefreshCw className="w-3 h-3 mr-1" />
)}
Refresh
</Button>
</div>
<Select value={selectedRemote} onValueChange={setSelectedRemote}>
<SelectTrigger id="remote-select">
<SelectValue placeholder="Select a remote" />
</SelectTrigger>
<SelectContent>
{remotes.map((remote) => (
<SelectItem
key={remote.name}
value={remote.name}
description={
<span className="text-xs text-muted-foreground truncate max-w-[300px]">
{remote.url}
</span>
}
>
<span className="font-medium">{remote.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedRemote && (
<div className="mt-2 p-3 rounded-md bg-muted/50 border border-border">
<p className="text-sm text-muted-foreground">
{isPull ? (
<>
This will pull changes from{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>{' '}
into your local branch.
</>
) : (
<>
This will push your local changes to{' '}
<span className="font-mono text-foreground">
{selectedRemote}/{worktree?.branch}
</span>
.
</>
)}
</p>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleConfirm} disabled={!selectedRemote || isLoading}>
<Icon className="w-4 h-4 mr-2" />
{actionLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,623 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import {
Archive,
FilePlus,
FileX,
FilePen,
FileText,
File,
ChevronDown,
ChevronRight,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { FileStatus } from '@/types/electron';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface StashChangesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
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':
case '?':
return <FilePlus className="w-3.5 h-3.5 text-green-500 flex-shrink-0" />;
case 'D':
return <FileX className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
case 'M':
case 'U':
return <FilePen className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />;
case 'R':
case 'C':
return <File className="w-3.5 h-3.5 text-blue-500 flex-shrink-0" />;
default:
return <FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />;
}
};
const getStatusLabel = (status: string) => {
switch (status) {
case 'A':
return 'Added';
case '?':
return 'Untracked';
case 'D':
return 'Deleted';
case 'M':
return 'Modified';
case 'U':
return 'Updated';
case 'R':
return 'Renamed';
case 'C':
return 'Copied';
default:
return 'Changed';
}
};
const getStatusBadgeColor = (status: string) => {
switch (status) {
case 'A':
case '?':
return 'bg-green-500/20 text-green-400 border-green-500/30';
case 'D':
return 'bg-red-500/20 text-red-400 border-red-500/30';
case 'M':
case 'U':
return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'R':
case 'C':
return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
default:
return 'bg-muted text-muted-foreground border-border';
}
};
/**
* 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;
}
function DiffLine({
type,
content,
lineNumber,
}: {
type: 'context' | 'addition' | 'deletion' | 'header';
content: string;
lineNumber?: { old?: number; new?: number };
}) {
const bgClass = {
context: 'bg-transparent',
addition: 'bg-green-500/10',
deletion: 'bg-red-500/10',
header: 'bg-blue-500/10',
};
const textClass = {
context: 'text-foreground-secondary',
addition: 'text-green-400',
deletion: 'text-red-400',
header: 'text-blue-400',
};
const prefix = {
context: ' ',
addition: '+',
deletion: '-',
header: '',
};
if (type === 'header') {
return (
<div className={cn('px-2 py-1 font-mono text-xs', bgClass[type], textClass[type])}>
{content}
</div>
);
}
return (
<div className={cn('flex font-mono text-xs', bgClass[type])}>
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
{lineNumber?.old ?? ''}
</span>
<span className="w-10 flex-shrink-0 text-right pr-1.5 text-muted-foreground select-none border-r border-border-glass text-[10px]">
{lineNumber?.new ?? ''}
</span>
<span className={cn('w-4 flex-shrink-0 text-center select-none', textClass[type])}>
{prefix[type]}
</span>
<span className={cn('flex-1 px-1.5 whitespace-pre-wrap break-all', textClass[type])}>
{content || '\u00A0'}
</span>
</div>
);
}
export function StashChangesDialog({
open,
onOpenChange,
worktree,
onStashed,
}: StashChangesDialogProps) {
const [message, setMessage] = useState('');
const [isStashing, setIsStashing] = useState(false);
// File selection state
const [files, setFiles] = useState<FileStatus[]>([]);
const [diffContent, setDiffContent] = useState('');
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [expandedFile, setExpandedFile] = useState<string | null>(null);
const [isLoadingDiffs, setIsLoadingDiffs] = useState(false);
// Parse diffs
const parsedDiffs = useMemo(() => parseDiff(diffContent), [diffContent]);
// Create a map of file path to parsed diff for quick lookup
const diffsByFile = useMemo(() => {
const map = new Map<string, ParsedFileDiff>();
for (const diff of parsedDiffs) {
map.set(diff.filePath, diff);
}
return map;
}, [parsedDiffs]);
// Load diffs when dialog opens
useEffect(() => {
if (open && worktree) {
setIsLoadingDiffs(true);
setFiles([]);
setDiffContent('');
setSelectedFiles(new Set());
setExpandedFile(null);
let cancelled = false;
const loadDiffs = async () => {
try {
const api = getHttpApiClient();
const result = await api.git.getDiffs(worktree.path);
if (result.success) {
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: FileStatus) => f.path)));
}
} catch (err) {
console.warn('Failed to load diffs for stash dialog:', err);
} finally {
if (!cancelled) setIsLoadingDiffs(false);
}
};
loadDiffs();
return () => {
cancelled = true;
};
}
}, [open, worktree]);
const handleToggleFile = useCallback((filePath: string) => {
setSelectedFiles((prev) => {
const next = new Set(prev);
if (next.has(filePath)) {
next.delete(filePath);
} else {
next.add(filePath);
}
return next;
});
}, []);
const handleToggleAll = useCallback(() => {
setSelectedFiles((prev) => {
if (prev.size === files.length) {
return new Set();
}
return new Set(files.map((f) => f.path));
});
}, [files]);
const handleFileClick = useCallback((filePath: string) => {
setExpandedFile((prev) => (prev === filePath ? null : filePath));
}, []);
const handleStash = async () => {
if (!worktree || selectedFiles.size === 0) return;
setIsStashing(true);
try {
const api = getHttpApiClient();
// Pass selected files if not all files are selected
const filesToStash =
selectedFiles.size === files.length ? undefined : Array.from(selectedFiles);
const result = await api.worktree.stashPush(
worktree.path,
message.trim() || undefined,
filesToStash
);
if (result.success && result.result) {
if (result.result.stashed) {
toast.success('Changes stashed', {
description: result.result.message || 'Your changes have been stashed',
});
setMessage('');
onOpenChange(false);
onStashed?.();
} else {
toast.info('No changes to stash');
}
} else {
toast.error('Failed to stash changes', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to stash changes', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setIsStashing(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey) && !isStashing && selectedFiles.size > 0) {
e.preventDefault();
handleStash();
}
};
if (!worktree) return null;
const allSelected = selectedFiles.size === files.length && files.length > 0;
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
setMessage('');
}
onOpenChange(isOpen);
}}
>
<DialogContent
className="sm:max-w-[700px] max-h-[85vh] flex flex-col"
onKeyDown={handleKeyDown}
>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5" />
Stash Changes
</DialogTitle>
<DialogDescription>
Stash uncommitted changes on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-2 min-h-0 flex-1 overflow-hidden">
{/* File Selection */}
<div className="flex flex-col min-h-0">
<div className="flex items-center justify-between mb-1.5">
<Label className="text-sm font-medium flex items-center gap-2">
Files to stash
{isLoadingDiffs ? (
<Spinner size="sm" />
) : (
<span className="text-xs text-muted-foreground font-normal">
({selectedFiles.size}/{files.length} selected)
</span>
)}
</Label>
{files.length > 0 && (
<button
onClick={handleToggleAll}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{allSelected ? 'Deselect all' : 'Select all'}
</button>
)}
</div>
{isLoadingDiffs ? (
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
<Spinner size="sm" className="mr-2" />
<span className="text-sm">Loading changes...</span>
</div>
) : files.length === 0 ? (
<div className="flex items-center justify-center py-6 text-muted-foreground border border-border rounded-lg">
<span className="text-sm">No changes detected</span>
</div>
) : (
<div className="border border-border rounded-lg overflow-hidden max-h-[300px] overflow-y-auto scrollbar-visible">
{files.map((file) => {
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;
return (
<div key={file.path} className="border-b border-border last:border-b-0">
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 hover:bg-accent/50 transition-colors group',
isExpanded && 'bg-accent/30'
)}
>
{/* Checkbox */}
<Checkbox
checked={isChecked}
onCheckedChange={() => handleToggleFile(file.path)}
className="flex-shrink-0"
/>
{/* Clickable file row to show diff */}
<button
onClick={() => handleFileClick(file.path)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3 text-muted-foreground flex-shrink-0" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground flex-shrink-0" />
)}
{getFileIcon(file.status)}
<span className="text-xs font-mono truncate flex-1 text-foreground">
{file.path}
</span>
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded border font-medium flex-shrink-0',
getStatusBadgeColor(file.status)
)}
>
{getStatusLabel(file.status)}
</span>
{additions > 0 && (
<span className="text-[10px] text-green-400 flex-shrink-0">
+{additions}
</span>
)}
{deletions > 0 && (
<span className="text-[10px] text-red-400 flex-shrink-0">
-{deletions}
</span>
)}
</button>
</div>
{/* Expanded diff view */}
{isExpanded && fileDiff && (
<div className="bg-background border-t border-border max-h-[200px] overflow-y-auto scrollbar-visible">
{fileDiff.hunks.map((hunk, hunkIndex) => (
<div
key={hunkIndex}
className="border-b border-border-glass last:border-b-0"
>
{hunk.lines.map((line, lineIndex) => (
<DiffLine
key={lineIndex}
type={line.type}
content={line.content}
lineNumber={line.lineNumber}
/>
))}
</div>
))}
</div>
)}
{isExpanded && !fileDiff && (
<div className="px-4 py-3 text-xs text-muted-foreground bg-background border-t border-border">
{file.status === '?' ? (
<span>New file - diff preview not available</span>
) : file.status === 'D' ? (
<span>File deleted</span>
) : (
<span>Diff content not available</span>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Stash Message */}
<div className="space-y-2">
<label htmlFor="stash-message" className="text-sm font-medium">
Stash message <span className="text-muted-foreground">(optional)</span>
</label>
<Input
id="stash-message"
placeholder="e.g., Work in progress on login page"
value={message}
onChange={(e) => setMessage(e.target.value)}
disabled={isStashing}
autoFocus
/>
<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
</kbd>{' '}
to stash.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isStashing}>
Cancel
</Button>
<Button onClick={handleStash} disabled={isStashing || selectedFiles.size === 0}>
{isStashing ? (
<>
<Spinner size="xs" className="mr-2" />
Stashing...
</>
) : (
<>
<Archive className="w-4 h-4 mr-2" />
Stash
{selectedFiles.size > 0 && selectedFiles.size < files.length
? ` (${selectedFiles.size} file${selectedFiles.size > 1 ? 's' : ''})`
: ' Changes'}
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,323 @@
import { useCallback, useEffect, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
GitCommit,
User,
Clock,
Copy,
Check,
ChevronDown,
ChevronRight,
FileText,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface CommitInfo {
hash: string;
shortHash: string;
author: string;
authorEmail: string;
date: string;
subject: string;
body: string;
files: string[];
}
interface ViewCommitsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
}
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 5) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
return date.toLocaleDateString();
}
function CopyHashButton({ hash }: { hash: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(hash);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy hash');
}
};
return (
<button
onClick={handleCopy}
className="inline-flex items-center gap-1 font-mono text-[11px] bg-muted hover:bg-muted/80 px-1.5 py-0.5 rounded cursor-pointer transition-colors"
title={`Copy full hash: ${hash}`}
>
{copied ? (
<Check className="w-2.5 h-2.5 text-green-500" />
) : (
<Copy className="w-2.5 h-2.5 text-muted-foreground" />
)}
<span className="text-muted-foreground">{hash.slice(0, 7)}</span>
</button>
);
}
function CommitEntryItem({
commit,
index,
isLast,
}: {
commit: CommitInfo;
index: number;
isLast: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const hasFiles = commit.files && commit.files.length > 0;
return (
<div
className={cn('group relative rounded-md transition-colors', index === 0 && 'bg-muted/30')}
>
<div className="flex gap-3 py-2.5 px-3 hover:bg-muted/50 transition-colors rounded-md">
{/* Timeline dot and line */}
<div className="flex flex-col items-center pt-1.5 shrink-0">
<div
className={cn(
'w-2 h-2 rounded-full border-2',
index === 0 ? 'border-primary bg-primary' : 'border-muted-foreground/40 bg-background'
)}
/>
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
</div>
{/* Commit content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium leading-snug break-words">{commit.subject}</p>
<CopyHashButton hash={commit.hash} />
</div>
{commit.body && (
<p className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words line-clamp-3">
{commit.body}
</p>
)}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-1.5 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<User className="w-3 h-3" />
{commit.author}
</span>
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
<time dateTime={commit.date} title={new Date(commit.date).toLocaleString()}>
{formatRelativeDate(commit.date)}
</time>
</span>
{hasFiles && (
<button
onClick={() => setExpanded(!expanded)}
className="inline-flex items-center gap-1 hover:text-foreground transition-colors cursor-pointer"
>
{expanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
<FileText className="w-3 h-3" />
{commit.files.length} file{commit.files.length !== 1 ? 's' : ''}
</button>
)}
</div>
</div>
</div>
{/* Expanded file list */}
{expanded && hasFiles && (
<div className="border-t px-3 py-2 bg-muted/30">
<div className="space-y-0.5">
{commit.files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
>
<FileText className="w-3 h-3 shrink-0" />
<span className="font-mono break-all">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
const INITIAL_COMMIT_LIMIT = 30;
const LOAD_MORE_INCREMENT = 30;
const MAX_COMMIT_LIMIT = 100;
export function ViewCommitsDialog({ open, onOpenChange, worktree }: ViewCommitsDialogProps) {
const [commits, setCommits] = useState<CommitInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [limit, setLimit] = useState(INITIAL_COMMIT_LIMIT);
const [hasMore, setHasMore] = useState(false);
const fetchCommits = useCallback(
async (fetchLimit: number, isLoadMore = false) => {
if (isLoadMore) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
setError(null);
setCommits([]);
}
try {
const api = getHttpApiClient();
const result = await api.worktree.getCommitLog(worktree!.path, fetchLimit);
if (result.success && result.result) {
// Ensure each commit has a files array (backwards compat if server hasn't been rebuilt)
const fetchedCommits = result.result.commits.map((c: CommitInfo) => ({
...c,
files: c.files || [],
}));
setCommits(fetchedCommits);
// If we got back exactly as many commits as we requested, there may be more
setHasMore(fetchedCommits.length === fetchLimit && fetchLimit < MAX_COMMIT_LIMIT);
} else {
setError(result.error || 'Failed to load commits');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load commits');
} finally {
setIsLoading(false);
setIsLoadingMore(false);
}
},
[worktree]
);
useEffect(() => {
if (!open || !worktree) return;
setLimit(INITIAL_COMMIT_LIMIT);
setHasMore(false);
fetchCommits(INITIAL_COMMIT_LIMIT);
}, [open, worktree, fetchCommits]);
const handleLoadMore = () => {
const newLimit = Math.min(limit + LOAD_MORE_INCREMENT, MAX_COMMIT_LIMIT);
setLimit(newLimit);
fetchCommits(newLimit, true);
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitCommit className="w-5 h-5" />
Commit History
</DialogTitle>
<DialogDescription>
Recent commits on{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[400px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Spinner size="md" />
<span className="ml-2 text-sm text-muted-foreground">Loading commits...</span>
</div>
)}
{error && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{!isLoading && !error && commits.length === 0 && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-muted-foreground">No commits found</p>
</div>
)}
{!isLoading && !error && commits.length > 0 && (
<div className="space-y-0.5 mt-2">
{commits.map((commit, index) => (
<CommitEntryItem
key={commit.hash}
commit={commit}
index={index}
isLast={index === commits.length - 1 && !hasMore}
/>
))}
{hasMore && (
<div className="flex justify-center pt-3 pb-1">
<button
onClick={handleLoadMore}
disabled={isLoadingMore}
className="inline-flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer px-4 py-2 rounded-md hover:bg-muted/50"
>
{isLoadingMore ? (
<>
<Spinner size="sm" />
Loading more commits...
</>
) : (
<>
<ChevronDown className="w-4 h-4" />
Load more commits
</>
)}
</button>
</div>
)}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,410 @@
import { useEffect, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Archive,
ChevronDown,
ChevronRight,
Clock,
FileText,
GitBranch,
Play,
Trash2,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { getHttpApiClient } from '@/lib/http-api-client';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
interface WorktreeInfo {
path: string;
branch: string;
isMain: boolean;
hasChanges?: boolean;
changedFilesCount?: number;
}
interface StashEntry {
index: number;
message: string;
branch: string;
date: string;
files: string[];
}
interface ViewStashesDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
worktree: WorktreeInfo | null;
onStashApplied?: () => void;
}
function formatRelativeDate(dateStr: string): string {
const date = new Date(dateStr);
if (isNaN(date.getTime())) return 'Unknown date';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSecs = Math.floor(diffMs / 1000);
const diffMins = Math.floor(diffSecs / 60);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffSecs < 60) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
if (diffWeeks < 5) return `${diffWeeks}w ago`;
if (diffMonths < 12) return `${diffMonths}mo ago`;
return date.toLocaleDateString();
}
function StashEntryItem({
stash,
onApply,
onPop,
onDrop,
isApplying,
isDropping,
}: {
stash: StashEntry;
onApply: (index: number) => void;
onPop: (index: number) => void;
onDrop: (index: number) => void;
isApplying: boolean;
isDropping: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const isBusy = isApplying || isDropping;
// Clean up the stash message for display
const displayMessage =
stash.message.replace(/^(WIP on|On) [^:]+:\s*[a-f0-9]+\s*/, '').trim() || stash.message;
return (
<div
className={cn(
'group relative rounded-md border bg-card transition-colors',
'hover:border-primary/30'
)}
>
{/* Header */}
<div className="flex items-start gap-3 p-3">
{/* Expand toggle & stash icon */}
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 pt-0.5 text-muted-foreground hover:text-foreground transition-colors"
disabled={stash.files.length === 0}
>
{stash.files.length > 0 ? (
expanded ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronRight className="w-3.5 h-3.5" />
)
) : (
<span className="w-3.5" />
)}
<Archive className="w-3.5 h-3.5" />
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium leading-snug break-words">{displayMessage}</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1 font-mono bg-muted px-1.5 py-0.5 rounded text-[10px]">
stash@{'{' + stash.index + '}'}
</span>
{stash.branch && (
<span className="inline-flex items-center gap-1">
<GitBranch className="w-3 h-3" />
{stash.branch}
</span>
)}
<span className="inline-flex items-center gap-1">
<Clock className="w-3 h-3" />
<time
dateTime={stash.date}
title={
!isNaN(new Date(stash.date).getTime())
? new Date(stash.date).toLocaleString()
: stash.date
}
>
{formatRelativeDate(stash.date)}
</time>
</span>
{stash.files.length > 0 && (
<span className="inline-flex items-center gap-1">
<FileText className="w-3 h-3" />
{stash.files.length} file{stash.files.length !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
{/* Action buttons */}
<div className="flex items-center gap-1 shrink-0">
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onApply(stash.index)}
disabled={isBusy}
title="Apply stash (keep in stash list)"
>
{isApplying ? <Spinner size="xs" /> : <Play className="w-3 h-3 mr-1" />}
Apply
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs px-2"
onClick={() => onPop(stash.index)}
disabled={isBusy}
title="Pop stash (apply and remove from stash list)"
>
{isApplying ? <Spinner size="xs" /> : 'Pop'}
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
onClick={() => onDrop(stash.index)}
disabled={isBusy}
title="Delete this stash"
>
{isDropping ? <Spinner size="xs" /> : <Trash2 className="w-3 h-3" />}
</Button>
</div>
</div>
</div>
</div>
{/* Expanded file list */}
{expanded && stash.files.length > 0 && (
<div className="border-t px-3 py-2 bg-muted/30">
<div className="space-y-0.5">
{stash.files.map((file) => (
<div
key={file}
className="flex items-center gap-2 text-xs text-muted-foreground py-0.5"
>
<FileText className="w-3 h-3 shrink-0" />
<span className="font-mono break-all">{file}</span>
</div>
))}
</div>
</div>
)}
</div>
);
}
export function ViewStashesDialog({
open,
onOpenChange,
worktree,
onStashApplied,
}: ViewStashesDialogProps) {
const [stashes, setStashes] = useState<StashEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [applyingIndex, setApplyingIndex] = useState<number | null>(null);
const [droppingIndex, setDroppingIndex] = useState<number | null>(null);
const fetchStashes = useCallback(async () => {
if (!worktree) return;
setIsLoading(true);
setError(null);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashList(worktree.path);
if (result.success && result.result) {
setStashes(result.result.stashes);
} else {
setError(result.error || 'Failed to load stashes');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load stashes');
} finally {
setIsLoading(false);
}
}, [worktree]);
useEffect(() => {
if (open && worktree) {
fetchStashes();
}
if (!open) {
setStashes([]);
setError(null);
}
}, [open, worktree, fetchStashes]);
const handleApply = async (stashIndex: number) => {
if (!worktree) return;
setApplyingIndex(stashIndex);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashApply(worktree.path, stashIndex, false);
if (result.success && result.result) {
if (result.result.hasConflicts) {
toast.warning('Stash applied with conflicts', {
description: 'Please resolve the merge conflicts.',
});
} else {
toast.success('Stash applied');
}
onStashApplied?.();
} else {
toast.error('Failed to apply stash', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to apply stash', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setApplyingIndex(null);
}
};
const handlePop = async (stashIndex: number) => {
if (!worktree) return;
setApplyingIndex(stashIndex);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashApply(worktree.path, stashIndex, true);
if (result.success && result.result) {
if (result.result.hasConflicts) {
toast.warning('Stash popped with conflicts', {
description: 'Please resolve the merge conflicts. The stash was removed.',
});
} else {
toast.success('Stash popped', {
description: 'Changes applied and stash removed.',
});
}
// Refresh the stash list since the stash was removed
await fetchStashes();
onStashApplied?.();
} else {
toast.error('Failed to pop stash', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to pop stash', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setApplyingIndex(null);
}
};
const handleDrop = async (stashIndex: number) => {
if (!worktree) return;
setDroppingIndex(stashIndex);
try {
const api = getHttpApiClient();
const result = await api.worktree.stashDrop(worktree.path, stashIndex);
if (result.success) {
toast.success('Stash deleted');
// Refresh the stash list
await fetchStashes();
} else {
toast.error('Failed to delete stash', {
description: result.error || 'Unknown error',
});
}
} catch (err) {
toast.error('Failed to delete stash', {
description: err instanceof Error ? err.message : 'Unknown error',
});
} finally {
setDroppingIndex(null);
}
};
if (!worktree) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-full h-full max-w-full max-h-full sm:w-[90vw] sm:max-w-[640px] sm:max-h-[100dvh] sm:h-auto sm:rounded-xl rounded-none flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Archive className="w-5 h-5" />
Stashes
</DialogTitle>
<DialogDescription>
Stashed changes in{' '}
<code className="font-mono bg-muted px-1 rounded">{worktree.branch}</code>
</DialogDescription>
</DialogHeader>
<div className="flex-1 sm:min-h-[300px] sm:max-h-[60vh] overflow-y-auto scrollbar-visible -mx-6 -mb-6">
<div className="h-full px-6 pb-6">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Spinner size="md" />
<span className="ml-2 text-sm text-muted-foreground">Loading stashes...</span>
</div>
)}
{error && (
<div className="flex items-center justify-center py-12">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
{!isLoading && !error && stashes.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 gap-2">
<Archive className="w-8 h-8 text-muted-foreground/50" />
<p className="text-sm text-muted-foreground">No stashes found</p>
<p className="text-xs text-muted-foreground">
Use &quot;Stash Changes&quot; to save your uncommitted changes
</p>
</div>
)}
{!isLoading && !error && stashes.length > 0 && (
<div className="space-y-2 mt-2">
{stashes.map((stash) => (
<StashEntryItem
key={stash.index}
stash={stash}
onApply={handleApply}
onPop={handlePop}
onDrop={handleDrop}
isApplying={applyingIndex === stash.index}
isDropping={droppingIndex === stash.index}
/>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -660,9 +660,28 @@ export function useBoardActions({
const handleVerifyFeature = useCallback(
async (feature: Feature) => {
if (!currentProject) return;
verifyFeatureMutation.mutate(feature.id);
try {
const result = await verifyFeatureMutation.mutateAsync(feature.id);
if (result.passes) {
// Immediately move card to verified column (optimistic update)
moveFeature(feature.id, 'verified');
persistFeatureUpdate(feature.id, {
status: 'verified',
justFinishedAt: undefined,
});
toast.success('Verification passed', {
description: `Verified: ${truncateDescription(feature.description)}`,
});
} else {
toast.error('Verification failed', {
description: `Tests did not pass for: ${truncateDescription(feature.description)}`,
});
}
} catch {
// Error toast is already shown by the mutation's onError handler
}
},
[currentProject, verifyFeatureMutation]
[currentProject, verifyFeatureMutation, moveFeature, persistFeatureUpdate]
);
const handleResumeFeature = useCallback(
@@ -1176,6 +1195,49 @@ export function useBoardActions({
[handleAddFeature]
);
const handleDuplicateAsChildMultiple = useCallback(
async (feature: Feature, count: number) => {
// Create a chain of duplicates, each a child of the previous, so they execute sequentially
let parentFeature = feature;
for (let i = 0; i < count; i++) {
const {
id: _id,
status: _status,
startedAt: _startedAt,
error: _error,
summary: _summary,
spec: _spec,
passes: _passes,
planSpec: _planSpec,
descriptionHistory: _descriptionHistory,
titleGenerating: _titleGenerating,
...featureData
} = parentFeature;
const duplicatedFeatureData = {
...featureData,
// Each duplicate depends on the previous one in the chain
dependencies: [parentFeature.id],
};
await handleAddFeature(duplicatedFeatureData);
// Get the newly created feature (last added feature) to use as parent for next iteration
const currentFeatures = useAppStore.getState().features;
const newestFeature = currentFeatures[currentFeatures.length - 1];
if (newestFeature) {
parentFeature = newestFeature;
}
}
toast.success(`Created ${count} chained duplicates`, {
description: `Created ${count} sequential copies of: ${truncateDescription(feature.description || feature.title || '')}`,
});
},
[handleAddFeature]
);
return {
handleAddFeature,
handleUpdateFeature,
@@ -1197,5 +1259,6 @@ export function useBoardActions({
handleStartNextFeatures,
handleArchiveAllVerified,
handleDuplicateFeature,
handleDuplicateAsChildMultiple,
};
}

View File

@@ -180,19 +180,17 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
(existing) => (existing ? existing.filter((f) => f.id !== featureId) : existing)
);
try {
const api = getElectronAPI();
if (!api.features) {
// Rollback optimistic deletion since we can't persist
if (previousFeatures) {
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
}
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw new Error('Features API not available');
}
const api = getElectronAPI();
if (!api.features) {
// Rollback optimistic deletion since we can't persist
queryClient.setQueryData(queryKeys.features.all(currentProject.path), previousFeatures);
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw new Error('Features API not available');
}
try {
await api.features.delete(currentProject.path, featureId);
// Invalidate to sync with server state
queryClient.invalidateQueries({
@@ -207,6 +205,7 @@ export function useBoardPersistence({ currentProject }: UseBoardPersistenceProps
queryClient.invalidateQueries({
queryKey: queryKeys.features.all(currentProject.path),
});
throw error;
}
},
[currentProject, queryClient]

View File

@@ -48,6 +48,7 @@ interface KanbanBoardProps {
onSpawnTask?: (feature: Feature) => void;
onDuplicate?: (feature: Feature) => void;
onDuplicateAsChild?: (feature: Feature) => void;
onDuplicateAsChildMultiple?: (feature: Feature) => void;
featuresWithContext: Set<string>;
runningAutoTasks: string[];
onArchiveAllVerified: () => void;
@@ -286,6 +287,7 @@ export function KanbanBoard({
onSpawnTask,
onDuplicate,
onDuplicateAsChild,
onDuplicateAsChildMultiple,
featuresWithContext,
runningAutoTasks,
onArchiveAllVerified,
@@ -575,6 +577,11 @@ export function KanbanBoard({
onSpawnTask={() => onSpawnTask?.(feature)}
onDuplicate={() => onDuplicate?.(feature)}
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
onDuplicateAsChildMultiple={
onDuplicateAsChildMultiple
? () => onDuplicateAsChildMultiple(feature)
: undefined
}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}
@@ -619,6 +626,11 @@ export function KanbanBoard({
onSpawnTask={() => onSpawnTask?.(feature)}
onDuplicate={() => onDuplicate?.(feature)}
onDuplicateAsChild={() => onDuplicateAsChild?.(feature)}
onDuplicateAsChildMultiple={
onDuplicateAsChildMultiple
? () => onDuplicateAsChildMultiple(feature)
: undefined
}
hasContext={featuresWithContext.has(feature.id)}
isCurrentAutoTask={runningAutoTasks.includes(feature.id)}
shortcutKey={shortcutKey}

View File

@@ -34,9 +34,13 @@ import {
Undo2,
Zap,
FlaskConical,
History,
Archive,
Cherry,
} from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { Spinner } from '@/components/ui/spinner';
import type { WorktreeInfo, DevServerInfo, PRInfo, GitRepoStatus, TestSessionInfo } from '../types';
import { TooltipWrapper } from './tooltip-wrapper';
import { useAvailableEditors, useEffectiveDefaultEditor } from '../hooks/use-available-editors';
@@ -60,6 +64,8 @@ interface WorktreeActionsDropdownProps {
isDevServerRunning: boolean;
devServerInfo?: DevServerInfo;
gitRepoStatus: GitRepoStatus;
/** When true, git repo status is still being loaded */
isLoadingGitStatus?: boolean;
/** When true, renders as a standalone button (not attached to another element) */
standalone?: boolean;
/** Whether auto mode is running for this worktree */
@@ -80,6 +86,7 @@ interface WorktreeActionsDropdownProps {
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onViewCommits: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
@@ -99,6 +106,12 @@ interface WorktreeActionsDropdownProps {
onStopTests?: (worktree: WorktreeInfo) => void;
/** View test logs for this worktree */
onViewTestLogs?: (worktree: WorktreeInfo) => void;
/** Stash changes for this worktree */
onStashChanges?: (worktree: WorktreeInfo) => void;
/** View stashes for this worktree */
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
}
@@ -114,6 +127,7 @@ export function WorktreeActionsDropdown({
isDevServerRunning,
devServerInfo,
gitRepoStatus,
isLoadingGitStatus = false,
standalone = false,
isAutoModeRunning = false,
hasTestCommand = false,
@@ -128,6 +142,7 @@ export function WorktreeActionsDropdown({
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onViewCommits,
onDiscardChanges,
onCommit,
onCreatePR,
@@ -144,6 +159,9 @@ export function WorktreeActionsDropdown({
onStartTests,
onStopTests,
onViewTestLogs,
onStashChanges,
onViewStashes,
onCherryPick,
hasInitScript,
}: WorktreeActionsDropdownProps) {
// Get available editors for the "Open In" submenu
@@ -203,8 +221,18 @@ export function WorktreeActionsDropdown({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
{/* Warning label when git operations are not available */}
{!canPerformGitOps && (
{/* Loading indicator while git status is being determined */}
{isLoadingGitStatus && (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-muted-foreground">
<Spinner size="xs" variant="muted" />
Checking git status...
</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
{/* Warning label when git operations are not available (only show once loaded) */}
{!isLoadingGitStatus && !canPerformGitOps && (
<>
<DropdownMenuLabel className="text-xs flex items-center gap-2 text-amber-600 dark:text-amber-400">
<AlertCircle className="w-3.5 h-3.5" />
@@ -387,10 +415,90 @@ export function WorktreeActionsDropdown({
)}
>
<GitMerge className="w-3.5 h-3.5 mr-2" />
Pull & Resolve Conflicts
Merge & Rebase
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem>
</TooltipWrapper>
<TooltipWrapper showTooltip={!!gitOpsDisabledReason} tooltipContent={gitOpsDisabledReason}>
<DropdownMenuItem
onClick={() => canPerformGitOps && onViewCommits(worktree)}
disabled={!canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<History className="w-3.5 h-3.5 mr-2" />
View Commits
{!canPerformGitOps && <AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />}
</DropdownMenuItem>
</TooltipWrapper>
{/* Cherry-pick commits from another branch */}
{onCherryPick && (
<TooltipWrapper
showTooltip={!!gitOpsDisabledReason}
tooltipContent={gitOpsDisabledReason}
>
<DropdownMenuItem
onClick={() => canPerformGitOps && onCherryPick(worktree)}
disabled={!canPerformGitOps}
className={cn('text-xs', !canPerformGitOps && 'opacity-50 cursor-not-allowed')}
>
<Cherry className="w-3.5 h-3.5 mr-2" />
Cherry Pick
{!canPerformGitOps && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
</TooltipWrapper>
)}
{/* Stash operations - combined submenu */}
{(onStashChanges || onViewStashes) && (
<TooltipWrapper
showTooltip={!gitRepoStatus.isGitRepo}
tooltipContent="Not a git repository"
>
<DropdownMenuSub>
<div className="flex items-center">
{/* Main clickable area - stash changes (primary action) */}
<DropdownMenuItem
onClick={() => {
if (!gitRepoStatus.isGitRepo) return;
if (worktree.hasChanges && onStashChanges) {
onStashChanges(worktree);
} else if (onViewStashes) {
onViewStashes(worktree);
}
}}
disabled={!gitRepoStatus.isGitRepo}
className={cn(
'text-xs flex-1 pr-0 rounded-r-none',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
)}
>
<Archive className="w-3.5 h-3.5 mr-2" />
{worktree.hasChanges && onStashChanges ? 'Stash Changes' : 'Stashes'}
{!gitRepoStatus.isGitRepo && (
<AlertCircle className="w-3 h-3 ml-auto text-muted-foreground" />
)}
</DropdownMenuItem>
{/* Chevron trigger for submenu with stash options */}
<DropdownMenuSubTrigger
className={cn(
'text-xs px-1 rounded-l-none border-l border-border/30 h-8',
!gitRepoStatus.isGitRepo && 'opacity-50 cursor-not-allowed'
)}
disabled={!gitRepoStatus.isGitRepo}
/>
</div>
<DropdownMenuSubContent>
{onViewStashes && (
<DropdownMenuItem onClick={() => onViewStashes(worktree)} className="text-xs">
<Eye className="w-3.5 h-3.5 mr-2" />
View Stashes
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
</TooltipWrapper>
)}
<DropdownMenuSeparator />
{/* Open in editor - split button: click main area for default, chevron for other options */}
{effectiveDefaultEditor && (

View File

@@ -91,6 +91,7 @@ export interface WorktreeDropdownProps {
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onViewCommits: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
@@ -107,6 +108,12 @@ export interface WorktreeDropdownProps {
onStartTests: (worktree: WorktreeInfo) => void;
onStopTests: (worktree: WorktreeInfo) => void;
onViewTestLogs: (worktree: WorktreeInfo) => void;
/** Stash changes for this worktree */
onStashChanges?: (worktree: WorktreeInfo) => void;
/** View stashes for this worktree */
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
}
/**
@@ -168,6 +175,7 @@ export function WorktreeDropdown({
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onViewCommits,
onDiscardChanges,
onCommit,
onCreatePR,
@@ -184,6 +192,9 @@ export function WorktreeDropdown({
onStartTests,
onStopTests,
onViewTestLogs,
onStashChanges,
onViewStashes,
onCherryPick,
}: WorktreeDropdownProps) {
// Find the currently selected worktree to display in the trigger
const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w));
@@ -442,6 +453,7 @@ export function WorktreeDropdown({
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isLoadingGitStatus={isLoadingBranches}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
@@ -455,6 +467,7 @@ export function WorktreeDropdown({
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onViewCommits={onViewCommits}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -471,6 +484,9 @@ export function WorktreeDropdown({
onStartTests={onStartTests}
onStopTests={onStopTests}
onViewTestLogs={onViewTestLogs}
onStashChanges={onStashChanges}
onViewStashes={onViewStashes}
onCherryPick={onCherryPick}
hasInitScript={hasInitScript}
/>
)}

View File

@@ -59,6 +59,7 @@ interface WorktreeTabProps {
onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void;
onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void;
onViewChanges: (worktree: WorktreeInfo) => void;
onViewCommits: (worktree: WorktreeInfo) => void;
onDiscardChanges: (worktree: WorktreeInfo) => void;
onCommit: (worktree: WorktreeInfo) => void;
onCreatePR: (worktree: WorktreeInfo) => void;
@@ -78,6 +79,12 @@ interface WorktreeTabProps {
onStopTests?: (worktree: WorktreeInfo) => void;
/** View test logs for this worktree */
onViewTestLogs?: (worktree: WorktreeInfo) => void;
/** Stash changes for this worktree */
onStashChanges?: (worktree: WorktreeInfo) => void;
/** View stashes for this worktree */
onViewStashes?: (worktree: WorktreeInfo) => void;
/** Cherry-pick commits from another branch */
onCherryPick?: (worktree: WorktreeInfo) => void;
hasInitScript: boolean;
/** Whether a test command is configured in project settings */
hasTestCommand?: boolean;
@@ -122,6 +129,7 @@ export function WorktreeTab({
onOpenInIntegratedTerminal,
onOpenInExternalTerminal,
onViewChanges,
onViewCommits,
onDiscardChanges,
onCommit,
onCreatePR,
@@ -138,6 +146,9 @@ export function WorktreeTab({
onStartTests,
onStopTests,
onViewTestLogs,
onStashChanges,
onViewStashes,
onCherryPick,
hasInitScript,
hasTestCommand = false,
}: WorktreeTabProps) {
@@ -418,6 +429,7 @@ export function WorktreeTab({
isDevServerRunning={isDevServerRunning}
devServerInfo={devServerInfo}
gitRepoStatus={gitRepoStatus}
isLoadingGitStatus={isLoadingBranches}
isAutoModeRunning={isAutoModeRunning}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
@@ -431,6 +443,7 @@ export function WorktreeTab({
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
onOpenInExternalTerminal={onOpenInExternalTerminal}
onViewChanges={onViewChanges}
onViewCommits={onViewCommits}
onDiscardChanges={onDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -447,6 +460,9 @@ export function WorktreeTab({
onStartTests={onStartTests}
onStopTests={onStopTests}
onViewTestLogs={onViewTestLogs}
onStashChanges={onStashChanges}
onViewStashes={onViewStashes}
onCherryPick={onCherryPick}
hasInitScript={hasInitScript}
/>
</div>

View File

@@ -46,18 +46,22 @@ export function useWorktreeActions(options?: UseWorktreeActionsOptions) {
);
const handlePull = useCallback(
async (worktree: WorktreeInfo) => {
async (worktree: WorktreeInfo, remote?: string) => {
if (pullMutation.isPending) return;
pullMutation.mutate(worktree.path);
pullMutation.mutate({
worktreePath: worktree.path,
remote,
});
},
[pullMutation]
);
const handlePush = useCallback(
async (worktree: WorktreeInfo) => {
async (worktree: WorktreeInfo, remote?: string) => {
if (pushMutation.isPending) return;
pushMutation.mutate({
worktreePath: worktree.path,
remote,
});
},
[pushMutation]

View File

@@ -33,10 +33,16 @@ import {
import { useAppStore } from '@/store/app-store';
import {
ViewWorktreeChangesDialog,
ViewCommitsDialog,
PushToRemoteDialog,
MergeWorktreeDialog,
DiscardWorktreeChangesDialog,
SelectRemoteDialog,
StashChangesDialog,
ViewStashesDialog,
CherryPickDialog,
} from '../dialogs';
import type { SelectRemoteOperation } from '../dialogs';
import { TestLogsPanel } from '@/components/ui/test-logs-panel';
import { getElectronAPI } from '@/lib/electron';
@@ -380,6 +386,10 @@ export function WorktreePanel({
const [viewChangesDialogOpen, setViewChangesDialogOpen] = useState(false);
const [viewChangesWorktree, setViewChangesWorktree] = useState<WorktreeInfo | null>(null);
// View commits dialog state
const [viewCommitsDialogOpen, setViewCommitsDialogOpen] = useState(false);
const [viewCommitsWorktree, setViewCommitsWorktree] = useState<WorktreeInfo | null>(null);
// Discard changes confirmation dialog state
const [discardChangesDialogOpen, setDiscardChangesDialogOpen] = useState(false);
const [discardChangesWorktree, setDiscardChangesWorktree] = useState<WorktreeInfo | null>(null);
@@ -396,6 +406,21 @@ export function WorktreePanel({
const [mergeDialogOpen, setMergeDialogOpen] = useState(false);
const [mergeWorktree, setMergeWorktree] = useState<WorktreeInfo | null>(null);
// Select remote dialog state (for pull/push with multiple remotes)
const [selectRemoteDialogOpen, setSelectRemoteDialogOpen] = useState(false);
const [selectRemoteWorktree, setSelectRemoteWorktree] = useState<WorktreeInfo | null>(null);
const [selectRemoteOperation, setSelectRemoteOperation] = useState<SelectRemoteOperation>('pull');
// Stash dialog states
const [stashChangesDialogOpen, setStashChangesDialogOpen] = useState(false);
const [stashChangesWorktree, setStashChangesWorktree] = useState<WorktreeInfo | null>(null);
const [viewStashesDialogOpen, setViewStashesDialogOpen] = useState(false);
const [viewStashesWorktree, setViewStashesWorktree] = useState<WorktreeInfo | null>(null);
// Cherry-pick dialog states
const [cherryPickDialogOpen, setCherryPickDialogOpen] = useState(false);
const [cherryPickWorktree, setCherryPickWorktree] = useState<WorktreeInfo | null>(null);
const isMobile = useIsMobile();
// Periodic interval check (30 seconds) to detect branch changes on disk
@@ -464,6 +489,11 @@ export function WorktreePanel({
setViewChangesDialogOpen(true);
}, []);
const handleViewCommits = useCallback((worktree: WorktreeInfo) => {
setViewCommitsWorktree(worktree);
setViewCommitsDialogOpen(true);
}, []);
const handleDiscardChanges = useCallback((worktree: WorktreeInfo) => {
setDiscardChangesWorktree(worktree);
setDiscardChangesDialogOpen(true);
@@ -473,6 +503,36 @@ export function WorktreePanel({
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle stash changes dialog
const handleStashChanges = useCallback((worktree: WorktreeInfo) => {
setStashChangesWorktree(worktree);
setStashChangesDialogOpen(true);
}, []);
const handleStashCompleted = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle view stashes dialog
const handleViewStashes = useCallback((worktree: WorktreeInfo) => {
setViewStashesWorktree(worktree);
setViewStashesDialogOpen(true);
}, []);
const handleStashApplied = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle cherry-pick dialog
const handleCherryPick = useCallback((worktree: WorktreeInfo) => {
setCherryPickWorktree(worktree);
setCherryPickDialogOpen(true);
}, []);
const handleCherryPicked = useCallback(() => {
fetchWorktrees({ silent: true });
}, [fetchWorktrees]);
// Handle opening the log panel for a specific worktree
const handleViewDevServerLogs = useCallback((worktree: WorktreeInfo) => {
setLogPanelWorktree(worktree);
@@ -491,6 +551,68 @@ export function WorktreePanel({
setPushToRemoteDialogOpen(true);
}, []);
// Handle pull with remote selection when multiple remotes exist
const handlePullWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('pull');
setSelectRemoteDialogOpen(true);
} else {
// Single or no remote - proceed with default behavior
handlePull(worktree);
}
} catch {
// If listing remotes fails, fall back to default behavior
handlePull(worktree);
}
},
[handlePull]
);
// Handle push with remote selection when multiple remotes exist
const handlePushWithRemoteSelection = useCallback(
async (worktree: WorktreeInfo) => {
try {
const api = getHttpApiClient();
const result = await api.worktree.listRemotes(worktree.path);
if (result.success && result.result && result.result.remotes.length > 1) {
// Multiple remotes - show selection dialog
setSelectRemoteWorktree(worktree);
setSelectRemoteOperation('push');
setSelectRemoteDialogOpen(true);
} else {
// Single or no remote - proceed with default behavior
handlePush(worktree);
}
} catch {
// If listing remotes fails, fall back to default behavior
handlePush(worktree);
}
},
[handlePush]
);
// Handle confirming remote selection for pull/push
const handleConfirmSelectRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
if (selectRemoteOperation === 'pull') {
handlePull(worktree, remote);
} else {
handlePush(worktree, remote);
}
fetchBranches(worktree.path);
fetchWorktrees();
},
[selectRemoteOperation, handlePull, handlePush, fetchBranches, fetchWorktrees]
);
// Handle confirming the push to remote dialog
const handleConfirmPushToRemote = useCallback(
async (worktree: WorktreeInfo, remote: string) => {
@@ -585,19 +707,21 @@ export function WorktreePanel({
isDevServerRunning={isDevServerRunning(selectedWorktree)}
devServerInfo={getDevServerInfo(selectedWorktree)}
gitRepoStatus={gitRepoStatus}
isLoadingGitStatus={isLoadingBranches}
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
hasTestCommand={hasTestCommand}
isStartingTests={isStartingTests}
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
testSessionInfo={getTestSessionInfo(selectedWorktree)}
onOpenChange={handleActionsDropdownOpenChange(selectedWorktree)}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -614,6 +738,9 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
hasInitScript={hasInitScript}
/>
)}
@@ -656,6 +783,13 @@ export function WorktreePanel({
projectPath={projectPath}
/>
{/* View Commits Dialog */}
<ViewCommitsDialog
open={viewCommitsDialogOpen}
onOpenChange={setViewCommitsDialogOpen}
worktree={viewCommitsWorktree}
/>
{/* Discard Changes Dialog */}
<DiscardWorktreeChangesDialog
open={discardChangesDialogOpen}
@@ -664,6 +798,31 @@ export function WorktreePanel({
onDiscarded={handleDiscardCompleted}
/>
{/* Stash Changes Dialog */}
<StashChangesDialog
open={stashChangesDialogOpen}
onOpenChange={setStashChangesDialogOpen}
worktree={stashChangesWorktree}
onStashed={handleStashCompleted}
/>
{/* View Stashes Dialog */}
<ViewStashesDialog
open={viewStashesDialogOpen}
onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree}
onStashApplied={handleStashApplied}
/>
{/* Cherry Pick Dialog */}
<CherryPickDialog
open={cherryPickDialogOpen}
onOpenChange={setCherryPickDialogOpen}
worktree={cherryPickWorktree}
onCherryPicked={handleCherryPicked}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
{/* Dev Server Logs Panel */}
<DevServerLogsPanel
open={logPanelOpen}
@@ -681,6 +840,15 @@ export function WorktreePanel({
onConfirm={handleConfirmPushToRemote}
/>
{/* Select Remote Dialog (for pull/push with multiple remotes) */}
<SelectRemoteDialog
open={selectRemoteDialogOpen}
onOpenChange={setSelectRemoteDialogOpen}
worktree={selectRemoteWorktree}
operation={selectRemoteOperation}
onConfirm={handleConfirmSelectRemote}
/>
{/* Merge Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
@@ -753,13 +921,14 @@ export function WorktreePanel({
isStartingTests={isStartingTests}
hasInitScript={hasInitScript}
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -776,6 +945,9 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
onCherryPick={handleCherryPick}
/>
{useWorktreesEnabled && (
@@ -846,13 +1018,14 @@ export function WorktreePanel({
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -869,6 +1042,8 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
@@ -919,13 +1094,14 @@ export function WorktreePanel({
onBranchFilterChange={setBranchFilter}
onSwitchBranch={handleSwitchBranch}
onCreateBranch={onCreateBranch}
onPull={handlePull}
onPush={handlePush}
onPull={handlePullWithRemoteSelection}
onPush={handlePushWithRemoteSelection}
onPushNewBranch={handlePushNewBranch}
onOpenInEditor={handleOpenInEditor}
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
onOpenInExternalTerminal={handleOpenInExternalTerminal}
onViewChanges={handleViewChanges}
onViewCommits={handleViewCommits}
onDiscardChanges={handleDiscardChanges}
onCommit={onCommit}
onCreatePR={onCreatePR}
@@ -942,6 +1118,8 @@ export function WorktreePanel({
onStartTests={handleStartTests}
onStopTests={handleStopTests}
onViewTestLogs={handleViewTestLogs}
onStashChanges={handleStashChanges}
onViewStashes={handleViewStashes}
hasInitScript={hasInitScript}
hasTestCommand={hasTestCommand}
/>
@@ -987,6 +1165,13 @@ export function WorktreePanel({
projectPath={projectPath}
/>
{/* View Commits Dialog */}
<ViewCommitsDialog
open={viewCommitsDialogOpen}
onOpenChange={setViewCommitsDialogOpen}
worktree={viewCommitsWorktree}
/>
{/* Discard Changes Dialog */}
<DiscardWorktreeChangesDialog
open={discardChangesDialogOpen}
@@ -1012,6 +1197,15 @@ export function WorktreePanel({
onConfirm={handleConfirmPushToRemote}
/>
{/* Select Remote Dialog (for pull/push with multiple remotes) */}
<SelectRemoteDialog
open={selectRemoteDialogOpen}
onOpenChange={setSelectRemoteDialogOpen}
worktree={selectRemoteWorktree}
operation={selectRemoteOperation}
onConfirm={handleConfirmSelectRemote}
/>
{/* Merge Branch Dialog */}
<MergeWorktreeDialog
open={mergeDialogOpen}
@@ -1032,6 +1226,31 @@ export function WorktreePanel({
testLogsPanelWorktree ? () => handleStopTests(testLogsPanelWorktree) : undefined
}
/>
{/* Stash Changes Dialog */}
<StashChangesDialog
open={stashChangesDialogOpen}
onOpenChange={setStashChangesDialogOpen}
worktree={stashChangesWorktree}
onStashed={handleStashCompleted}
/>
{/* View Stashes Dialog */}
<ViewStashesDialog
open={viewStashesDialogOpen}
onOpenChange={setViewStashesDialogOpen}
worktree={viewStashesWorktree}
onStashApplied={handleStashApplied}
/>
{/* Cherry Pick Dialog */}
<CherryPickDialog
open={cherryPickDialogOpen}
onOpenChange={setCherryPickDialogOpen}
worktree={cherryPickWorktree}
onCherryPicked={handleCherryPicked}
onCreateConflictResolutionFeature={onCreateMergeConflictResolutionFeature}
/>
</div>
);
}

View File

@@ -397,7 +397,7 @@ export function LoginView() {
// Login form (awaiting_login or logging_in)
const isLoggingIn = state.phase === 'logging_in';
const apiKey = state.phase === 'awaiting_login' ? state.apiKey : state.apiKey;
const apiKey = state.apiKey;
const error = state.phase === 'awaiting_login' ? state.error : null;
return (

View File

@@ -10,6 +10,7 @@ import { ProjectModelsSection } from './project-models-section';
import { DataManagementSection } from './data-management-section';
import { DangerZoneSection } from '../settings-view/danger-zone/danger-zone-section';
import { DeleteProjectDialog } from '../settings-view/components/delete-project-dialog';
import { RemoveFromAutomakerDialog } from '../settings-view/components/remove-from-automaker-dialog';
import { ProjectSettingsNavigation } from './components/project-settings-navigation';
import { useProjectSettingsView } from './hooks/use-project-settings-view';
import type { Project as ElectronProject } from '@/lib/electron';
@@ -28,8 +29,9 @@ interface SettingsProject {
}
export function ProjectSettingsView() {
const { currentProject, moveProjectToTrash } = useAppStore();
const { currentProject, moveProjectToTrash, removeProject } = useAppStore();
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showRemoveFromAutomakerDialog, setShowRemoveFromAutomakerDialog] = useState(false);
// Use project settings view navigation hook
const { activeView, navigateTo } = useProjectSettingsView();
@@ -98,6 +100,7 @@ export function ProjectSettingsView() {
<DangerZoneSection
project={settingsProject}
onDeleteClick={() => setShowDeleteDialog(true)}
onRemoveFromAutomakerClick={() => setShowRemoveFromAutomakerDialog(true)}
/>
);
default:
@@ -178,6 +181,14 @@ export function ProjectSettingsView() {
project={currentProject}
onConfirm={moveProjectToTrash}
/>
{/* Remove from Automaker Confirmation Dialog */}
<RemoveFromAutomakerDialog
open={showRemoveFromAutomakerDialog}
onOpenChange={setShowRemoveFromAutomakerDialog}
project={currentProject}
onConfirm={removeProject}
/>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ShellSyntaxEditor } from '@/components/ui/shell-syntax-editor';
import {
GitBranch,
@@ -11,6 +12,9 @@ import {
RotateCcw,
Trash2,
PanelBottomClose,
Copy,
Plus,
FolderOpen,
} from 'lucide-react';
import { Spinner } from '@/components/ui/spinner';
import { cn } from '@/lib/utils';
@@ -19,6 +23,7 @@ import { toast } from 'sonner';
import { useAppStore } from '@/store/app-store';
import { getHttpApiClient } from '@/lib/http-api-client';
import type { Project } from '@/lib/electron';
import { ProjectFileSelectorDialog } from '@/components/dialogs/project-file-selector-dialog';
interface WorktreePreferencesSectionProps {
project: Project;
@@ -42,6 +47,8 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const getAutoDismissInitScriptIndicator = useAppStore((s) => s.getAutoDismissInitScriptIndicator);
const setAutoDismissInitScriptIndicator = useAppStore((s) => s.setAutoDismissInitScriptIndicator);
const getWorktreeCopyFiles = useAppStore((s) => s.getWorktreeCopyFiles);
const setWorktreeCopyFiles = useAppStore((s) => s.setWorktreeCopyFiles);
// Get effective worktrees setting (project override or global fallback)
const projectUseWorktrees = getProjectUseWorktrees(project.path);
@@ -54,6 +61,11 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Copy files state
const [newCopyFilePath, setNewCopyFilePath] = useState('');
const [fileSelectorOpen, setFileSelectorOpen] = useState(false);
const copyFiles = getWorktreeCopyFiles(project.path);
// Get the current settings for this project
const showIndicator = getShowInitScriptIndicator(project.path);
const defaultDeleteBranch = getDefaultDeleteBranch(project.path);
@@ -93,6 +105,9 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
response.settings.autoDismissInitScriptIndicator
);
}
if (response.settings.worktreeCopyFiles !== undefined) {
setWorktreeCopyFiles(currentPath, response.settings.worktreeCopyFiles);
}
}
} catch (error) {
if (!isCancelled) {
@@ -112,6 +127,7 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
setShowInitScriptIndicator,
setDefaultDeleteBranch,
setAutoDismissInitScriptIndicator,
setWorktreeCopyFiles,
]);
// Load init script content when project changes
@@ -219,6 +235,97 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
setScriptContent(value);
}, []);
// Add a new file path to copy list
const handleAddCopyFile = useCallback(async () => {
const trimmed = newCopyFilePath.trim();
if (!trimmed) return;
// Normalize: remove leading ./ or /
const normalized = trimmed.replace(/^\.\//, '').replace(/^\//, '');
if (!normalized) return;
// Check for duplicates
const currentFiles = getWorktreeCopyFiles(project.path);
if (currentFiles.includes(normalized)) {
toast.error('File already in list', {
description: `"${normalized}" is already configured for copying.`,
});
return;
}
const updatedFiles = [...currentFiles, normalized];
setWorktreeCopyFiles(project.path, updatedFiles);
setNewCopyFilePath('');
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success('Copy file added', {
description: `"${normalized}" will be copied to new worktrees.`,
});
} catch (error) {
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
}, [project.path, newCopyFilePath, getWorktreeCopyFiles, setWorktreeCopyFiles]);
// Remove a file path from copy list
const handleRemoveCopyFile = useCallback(
async (filePath: string) => {
const currentFiles = getWorktreeCopyFiles(project.path);
const updatedFiles = currentFiles.filter((f) => f !== filePath);
setWorktreeCopyFiles(project.path, updatedFiles);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success('Copy file removed');
} catch (error) {
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
},
[project.path, getWorktreeCopyFiles, setWorktreeCopyFiles]
);
// Handle files selected from the file selector dialog
const handleFileSelectorSelect = useCallback(
async (paths: string[]) => {
const currentFiles = getWorktreeCopyFiles(project.path);
// Filter out duplicates
const newPaths = paths.filter((p) => !currentFiles.includes(p));
if (newPaths.length === 0) {
toast.info('All selected files are already in the list');
return;
}
const updatedFiles = [...currentFiles, ...newPaths];
setWorktreeCopyFiles(project.path, updatedFiles);
// Persist to server
try {
const httpClient = getHttpApiClient();
await httpClient.settings.updateProject(project.path, {
worktreeCopyFiles: updatedFiles,
});
toast.success(`${newPaths.length} ${newPaths.length === 1 ? 'file' : 'files'} added`, {
description: newPaths.map((p) => `"${p}"`).join(', '),
});
} catch (error) {
console.error('Failed to persist worktreeCopyFiles:', error);
toast.error('Failed to save copy files setting');
}
},
[project.path, getWorktreeCopyFiles, setWorktreeCopyFiles]
);
return (
<div
className={cn(
@@ -387,6 +494,92 @@ export function WorktreePreferencesSection({ project }: WorktreePreferencesSecti
{/* Separator */}
<div className="border-t border-border/30" />
{/* Copy Files Section */}
<div className="space-y-3">
<div className="flex items-center gap-2">
<Copy className="w-4 h-4 text-brand-500" />
<Label className="text-foreground font-medium">Copy Files to Worktrees</Label>
</div>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
Specify files or directories (relative to project root) to automatically copy into new
worktrees. Useful for untracked files like{' '}
<code className="font-mono text-foreground/60">.env</code>,{' '}
<code className="font-mono text-foreground/60">.env.local</code>, or local config files
that aren&apos;t committed to git.
</p>
{/* Current file list */}
{copyFiles.length > 0 && (
<div className="space-y-1.5">
{copyFiles.map((filePath) => (
<div
key={filePath}
className="flex items-center gap-2 group/item px-3 py-1.5 rounded-lg bg-accent/20 hover:bg-accent/40 transition-colors"
>
<FileCode className="w-3.5 h-3.5 text-muted-foreground/60 flex-shrink-0" />
<code className="font-mono text-sm text-foreground/80 flex-1 truncate">
{filePath}
</code>
<button
onClick={() => handleRemoveCopyFile(filePath)}
className="p-0.5 rounded text-muted-foreground/50 hover:bg-destructive/10 hover:text-destructive transition-all flex-shrink-0"
title={`Remove ${filePath}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
{/* Add new file input */}
<div className="flex items-center gap-2">
<Input
value={newCopyFilePath}
onChange={(e) => setNewCopyFilePath(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddCopyFile();
}
}}
placeholder=".env, config/local.json, etc."
className="flex-1 h-8 text-sm font-mono"
/>
<Button
variant="outline"
size="sm"
onClick={handleAddCopyFile}
disabled={!newCopyFilePath.trim()}
className="gap-1.5 h-8"
>
<Plus className="w-3.5 h-3.5" />
Add
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setFileSelectorOpen(true)}
className="gap-1.5 h-8"
>
<FolderOpen className="w-3.5 h-3.5" />
Browse
</Button>
</div>
{/* File selector dialog */}
<ProjectFileSelectorDialog
open={fileSelectorOpen}
onOpenChange={setFileSelectorOpen}
onSelect={handleFileSelectorSelect}
projectPath={project.path}
existingFiles={copyFiles}
/>
</div>
{/* Separator */}
<div className="border-t border-border/30" />
{/* Init Script Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">

View File

@@ -1,4 +1,5 @@
export { DeleteProjectDialog } from './delete-project-dialog';
export { RemoveFromAutomakerDialog } from './remove-from-automaker-dialog';
export { KeyboardMapDialog } from './keyboard-map-dialog';
export { SettingsHeader } from './settings-header';
export { SettingsNavigation } from './settings-navigation';

View File

@@ -0,0 +1,49 @@
import { Folder, LogOut } from 'lucide-react';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import type { Project } from '@/lib/electron';
interface RemoveFromAutomakerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: Project | null;
onConfirm: (projectId: string) => void;
}
export function RemoveFromAutomakerDialog({
open,
onOpenChange,
project,
onConfirm,
}: RemoveFromAutomakerDialogProps) {
const handleConfirm = () => {
if (project) {
onConfirm(project.id);
}
};
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
onConfirm={handleConfirm}
title="Remove from Automaker"
description="Remove this project from Automaker? The folder will remain on disk and can be re-added later."
icon={LogOut}
iconClassName="text-muted-foreground"
confirmText="Remove from Automaker"
confirmVariant="secondary"
>
{project && (
<div className="flex items-center gap-3 p-4 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
<div className="w-10 h-10 rounded-lg bg-sidebar-accent/20 border border-sidebar-border flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground truncate">{project.path}</p>
</div>
</div>
)}
</ConfirmDialog>
);
}

View File

@@ -1,14 +1,19 @@
import { Button } from '@/components/ui/button';
import { Trash2, Folder, AlertTriangle } from 'lucide-react';
import { Trash2, Folder, AlertTriangle, LogOut } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Project } from '../shared/types';
interface DangerZoneSectionProps {
project: Project | null;
onDeleteClick: () => void;
onRemoveFromAutomakerClick?: () => void;
}
export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionProps) {
export function DangerZoneSection({
project,
onDeleteClick,
onRemoveFromAutomakerClick,
}: DangerZoneSectionProps) {
return (
<div
className={cn(
@@ -28,33 +33,57 @@ export function DangerZoneSection({ project, onDeleteClick }: DangerZoneSectionP
<p className="text-sm text-muted-foreground/80 ml-12">Destructive project actions.</p>
</div>
<div className="p-6 space-y-4">
{/* Project Delete */}
{project ? (
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
<>
{/* Remove from Automaker */}
{onRemoveFromAutomakerClick && (
<div className="flex items-start justify-between gap-4 p-4 rounded-xl bg-muted/30 border border-border">
<div className="min-w-0">
<p className="font-medium text-foreground">Remove from Automaker</p>
<p className="text-xs text-muted-foreground mt-0.5">
Remove this project from Automaker without deleting any files from disk. You can
re-add it later by opening the folder.
</p>
</div>
<Button
variant="secondary"
onClick={onRemoveFromAutomakerClick}
data-testid="remove-from-automaker-button"
className="shrink-0 transition-all duration-200 ease-out hover:scale-[1.02] active:scale-[0.98]"
>
<LogOut className="w-4 h-4 mr-2" />
Remove
</Button>
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
)}
{/* Project Delete / Move to Trash */}
<div className="flex items-center justify-between gap-4 p-4 rounded-xl bg-destructive/5 border border-destructive/10">
<div className="flex items-center gap-3.5 min-w-0">
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500/15 to-brand-600/10 border border-brand-500/20 flex items-center justify-center shrink-0">
<Folder className="w-5 h-5 text-brand-500" />
</div>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">{project.name}</p>
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">{project.path}</p>
</div>
</div>
<Button
variant="destructive"
onClick={onDeleteClick}
data-testid="delete-project-button"
className={cn(
'shrink-0',
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<Trash2 className="w-4 h-4 mr-2" />
Move to Trash
</Button>
</div>
<Button
variant="destructive"
onClick={onDeleteClick}
data-testid="delete-project-button"
className={cn(
'shrink-0',
'shadow-md shadow-destructive/20 hover:shadow-lg hover:shadow-destructive/25',
'transition-all duration-200 ease-out',
'hover:scale-[1.02] active:scale-[0.98]'
)}
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Project
</Button>
</div>
</>
) : (
<p className="text-sm text-muted-foreground/60 text-center py-4">No project selected.</p>
)}

View File

@@ -88,6 +88,9 @@ export function MobileTerminalShortcuts({
/** Handles arrow key press with long-press repeat support. */
const handleArrowPress = useCallback(
(data: string) => {
// Cancel any in-flight timeout/interval before starting a new one
// to prevent timer leaks when multiple touches occur.
clearRepeat();
sendKey(data);
// Start repeat after 400ms hold, then every 80ms
repeatTimeoutRef.current = setTimeout(() => {
@@ -96,7 +99,7 @@ export function MobileTerminalShortcuts({
}, 80);
}, 400);
},
[sendKey]
[clearRepeat, sendKey]
);
const handleArrowRelease = useCallback(() => {