mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-18 22:33:08 +00:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 */}
|
||||
{(() => {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 "Stash Changes" 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user