feat: Introduce default delete branch option for worktrees

This commit adds a new feature allowing users to set a default value for the "delete branch" checkbox when deleting a worktree. Key changes include:

1. **State Management**: Introduced `defaultDeleteBranchByProject` to manage the default delete branch setting per project.
2. **UI Components**: Updated the WorktreesSection to include a toggle for the default delete branch option, enhancing user control during worktree deletion.
3. **Dialog Updates**: Modified the DeleteWorktreeDialog to respect the default delete branch setting, improving the user experience by streamlining the deletion process.

These enhancements provide users with more flexibility and control over worktree management, improving overall project workflows.
This commit is contained in:
Kacper
2026-01-10 23:18:39 +01:00
parent aeb5bd829f
commit e902e8ea4c
7 changed files with 204 additions and 63 deletions

View File

@@ -119,6 +119,7 @@ export function BoardView() {
(state) => state.showInitScriptIndicatorByProject (state) => state.showInitScriptIndicatorByProject
); );
const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator); const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch);
const shortcuts = useKeyboardShortcutsConfig(); const shortcuts = useKeyboardShortcutsConfig();
const { const {
features: hookFeatures, features: hookFeatures,
@@ -1513,6 +1514,7 @@ export function BoardView() {
? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length ? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length
: 0 : 0
} }
defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)}
onDeleted={(deletedWorktree, _deletedBranch) => { onDeleted={(deletedWorktree, _deletedBranch) => {
// Reset features that were assigned to the deleted worktree (by branch) // Reset features that were assigned to the deleted worktree (by branch)
hookFeatures.forEach((feature) => { hookFeatures.forEach((feature) => {

View File

@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps {
onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void; onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void;
/** Number of features assigned to this worktree's branch */ /** Number of features assigned to this worktree's branch */
affectedFeatureCount?: number; affectedFeatureCount?: number;
/** Default value for the "delete branch" checkbox */
defaultDeleteBranch?: boolean;
} }
export function DeleteWorktreeDialog({ export function DeleteWorktreeDialog({
@@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({
worktree, worktree,
onDeleted, onDeleted,
affectedFeatureCount = 0, affectedFeatureCount = 0,
defaultDeleteBranch = false,
}: DeleteWorktreeDialogProps) { }: DeleteWorktreeDialogProps) {
const [deleteBranch, setDeleteBranch] = useState(false); const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Reset deleteBranch to default when dialog opens
useEffect(() => {
if (open) {
setDeleteBranch(defaultDeleteBranch);
}
}, [open, defaultDeleteBranch]);
const handleDelete = async () => { const handleDelete = async () => {
if (!worktree) return; if (!worktree) return;

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react'; import { Terminal, Check, X, Loader2, ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAppStore, type InitScriptState } from '@/store/app-store'; import { useAppStore, type InitScriptState } from '@/store/app-store';
@@ -8,45 +8,38 @@ interface InitScriptIndicatorProps {
projectPath: string; projectPath: string;
} }
export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { interface SingleIndicatorProps {
const initScriptState = useAppStore((s) => s.initScriptState[projectPath]); stateKey: string;
const clearInitScriptState = useAppStore((s) => s.clearInitScriptState); state: InitScriptState;
onDismiss: (key: string) => void;
isOnlyOne: boolean; // Whether this is the only indicator shown
}
function SingleIndicator({ stateKey, state, onDismiss, isOnlyOne }: SingleIndicatorProps) {
const [showLogs, setShowLogs] = useState(false); const [showLogs, setShowLogs] = useState(false);
const [dismissed, setDismissed] = useState(false);
const logsEndRef = useRef<HTMLDivElement>(null); const logsEndRef = useRef<HTMLDivElement>(null);
const { status, output, branch, error } = state;
// Auto-scroll to bottom when new output arrives // Auto-scroll to bottom when new output arrives
useEffect(() => { useEffect(() => {
if (showLogs && logsEndRef.current) { if (showLogs && logsEndRef.current) {
logsEndRef.current.scrollIntoView({ behavior: 'smooth' }); logsEndRef.current.scrollIntoView({ behavior: 'smooth' });
} }
}, [initScriptState?.output, showLogs]); }, [output, showLogs]);
// Reset dismissed state when a new script starts // Auto-expand logs when script starts (only if it's the only one or running)
useEffect(() => { useEffect(() => {
if (initScriptState?.status === 'running') { if (status === 'running' && isOnlyOne) {
setDismissed(false);
setShowLogs(true); setShowLogs(true);
} }
}, [initScriptState?.status]); }, [status, isOnlyOne]);
if (!initScriptState || dismissed) return null; if (status === 'idle') return null;
if (initScriptState.status === 'idle') return null;
const { status, output, branch, error } = initScriptState;
const handleDismiss = () => {
setDismissed(true);
// Clear state after a delay to allow for future scripts
setTimeout(() => {
clearInitScriptState(projectPath);
}, 100);
};
return ( return (
<div <div
className={cn( className={cn(
'fixed bottom-4 right-4 z-50',
'bg-card border border-border rounded-lg shadow-lg', 'bg-card border border-border rounded-lg shadow-lg',
'min-w-[350px] max-w-[500px]', 'min-w-[350px] max-w-[500px]',
'animate-in slide-in-from-right-5 duration-200' 'animate-in slide-in-from-right-5 duration-200'
@@ -55,18 +48,12 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-3 border-b border-border/50"> <div className="flex items-center justify-between p-3 border-b border-border/50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{status === 'running' && ( {status === 'running' && <Loader2 className="w-4 h-4 animate-spin text-blue-500" />}
<Loader2 className="w-4 h-4 animate-spin text-blue-500" />
)}
{status === 'success' && <Check className="w-4 h-4 text-green-500" />} {status === 'success' && <Check className="w-4 h-4 text-green-500" />}
{status === 'failed' && <X className="w-4 h-4 text-red-500" />} {status === 'failed' && <X className="w-4 h-4 text-red-500" />}
<span className="font-medium text-sm"> <span className="font-medium text-sm">
Init Script{' '} Init Script{' '}
{status === 'running' {status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'}
? 'Running'
: status === 'success'
? 'Completed'
: 'Failed'}
</span> </span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -83,7 +70,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
</button> </button>
{status !== 'running' && ( {status !== 'running' && (
<button <button
onClick={handleDismiss} onClick={() => onDismiss(stateKey)}
className="p-1 hover:bg-accent rounded transition-colors" className="p-1 hover:bg-accent rounded transition-colors"
title="Dismiss" title="Dismiss"
> >
@@ -110,11 +97,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
{status === 'running' ? 'Waiting for output...' : 'No output'} {status === 'running' ? 'Waiting for output...' : 'No output'}
</div> </div>
)} )}
{error && ( {error && <div className="mt-2 text-red-500 text-xs font-medium">Error: {error}</div>}
<div className="mt-2 text-red-500 text-xs font-medium">
Error: {error}
</div>
)}
<div ref={logsEndRef} /> <div ref={logsEndRef} />
</div> </div>
</div> </div>
@@ -125,9 +108,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
<div <div
className={cn( className={cn(
'px-3 py-2 text-xs', 'px-3 py-2 text-xs',
status === 'success' status === 'success' ? 'bg-green-500/10 text-green-600' : 'bg-red-500/10 text-red-600'
? 'bg-green-500/10 text-green-600'
: 'bg-red-500/10 text-red-600'
)} )}
> >
{status === 'success' {status === 'success'
@@ -138,3 +119,69 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
</div> </div>
); );
} }
export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
const getInitScriptStatesForProject = useAppStore((s) => s.getInitScriptStatesForProject);
const clearInitScriptState = useAppStore((s) => s.clearInitScriptState);
const [dismissedKeys, setDismissedKeys] = useState<Set<string>>(new Set());
// Get all init script states for this project
const allStates = getInitScriptStatesForProject(projectPath);
// Filter out dismissed and idle states
const activeStates = allStates.filter(
({ key, state }) => !dismissedKeys.has(key) && state.status !== 'idle'
);
// Reset dismissed keys when a new script starts for a branch
useEffect(() => {
const runningKeys = allStates
.filter(({ state }) => state.status === 'running')
.map(({ key }) => key);
if (runningKeys.length > 0) {
setDismissedKeys((prev) => {
const newSet = new Set(prev);
runningKeys.forEach((key) => newSet.delete(key));
return newSet;
});
}
}, [allStates]);
const handleDismiss = useCallback(
(key: string) => {
setDismissedKeys((prev) => new Set(prev).add(key));
// Extract branch from key (format: "projectPath::branch")
const branch = key.split('::')[1];
if (branch) {
// Clear state after a delay to allow for future scripts
setTimeout(() => {
clearInitScriptState(projectPath, branch);
}, 100);
}
},
[projectPath, clearInitScriptState]
);
if (activeStates.length === 0) return null;
return (
<div
className={cn(
'fixed bottom-4 right-4 z-50 flex flex-col gap-2',
'max-h-[calc(100vh-120px)] overflow-y-auto',
'scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent'
)}
>
{activeStates.map(({ key, state }) => (
<SingleIndicator
key={key}
stateKey={key}
state={state}
onDismiss={handleDismiss}
isOnlyOne={activeStates.length === 1}
/>
))}
</div>
);
}

View File

@@ -35,6 +35,8 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
const currentProject = useAppStore((s) => s.currentProject); const currentProject = useAppStore((s) => s.currentProject);
const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator); const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator);
const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator); const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator);
const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch);
const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch);
const [scriptContent, setScriptContent] = useState(''); const [scriptContent, setScriptContent] = useState('');
const [originalContent, setOriginalContent] = useState(''); const [originalContent, setOriginalContent] = useState('');
const [scriptExists, setScriptExists] = useState(false); const [scriptExists, setScriptExists] = useState(false);
@@ -47,6 +49,11 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
? getShowInitScriptIndicator(currentProject.path) ? getShowInitScriptIndicator(currentProject.path)
: true; : true;
// Get the default delete branch setting
const defaultDeleteBranch = currentProject?.path
? getDefaultDeleteBranch(currentProject.path)
: false;
// Check if there are unsaved changes // Check if there are unsaved changes
const hasChanges = scriptContent !== originalContent; const hasChanges = scriptContent !== originalContent;
@@ -226,6 +233,34 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre
</div> </div>
)} )}
{/* Default Delete Branch Toggle */}
{currentProject && (
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
<Checkbox
id="default-delete-branch"
checked={defaultDeleteBranch}
onCheckedChange={(checked) => {
if (currentProject?.path) {
setDefaultDeleteBranch(currentProject.path, checked === true);
}
}}
className="mt-1"
/>
<div className="space-y-1.5">
<Label
htmlFor="default-delete-branch"
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
>
<Trash2 className="w-4 h-4 text-brand-500" />
Delete Branch by Default
</Label>
<p className="text-xs text-muted-foreground/80 leading-relaxed">
When deleting a worktree, automatically check the "Also delete the branch" option.
</p>
</div>
</div>
)}
{/* Separator */} {/* Separator */}
<div className="border-t border-border/30" /> <div className="border-t border-border/30" />

View File

@@ -50,7 +50,7 @@ export function useInitScriptEvents(projectPath: string | null) {
switch (event.type) { switch (event.type) {
case 'worktree:init-started': { case 'worktree:init-started': {
const startPayload = payload as InitScriptStartedPayload; const startPayload = payload as InitScriptStartedPayload;
setInitScriptState(projectPath, { setInitScriptState(projectPath, startPayload.branch, {
status: 'running', status: 'running',
branch: startPayload.branch, branch: startPayload.branch,
output: [], output: [],
@@ -60,12 +60,12 @@ export function useInitScriptEvents(projectPath: string | null) {
} }
case 'worktree:init-output': { case 'worktree:init-output': {
const outputPayload = payload as InitScriptOutputPayload; const outputPayload = payload as InitScriptOutputPayload;
appendInitScriptOutput(projectPath, outputPayload.content); appendInitScriptOutput(projectPath, outputPayload.branch, outputPayload.content);
break; break;
} }
case 'worktree:init-completed': { case 'worktree:init-completed': {
const completePayload = payload as InitScriptCompletedPayload; const completePayload = payload as InitScriptCompletedPayload;
setInitScriptState(projectPath, { setInitScriptState(projectPath, completePayload.branch, {
status: completePayload.success ? 'success' : 'failed', status: completePayload.success ? 'success' : 'failed',
error: completePayload.error, error: completePayload.error,
}); });

View File

@@ -669,6 +669,10 @@ export interface AppState {
// Whether to show the floating init script indicator panel (default: true) // Whether to show the floating init script indicator panel (default: true)
showInitScriptIndicatorByProject: Record<string, boolean>; showInitScriptIndicatorByProject: Record<string, boolean>;
// Default Delete Branch With Worktree (per-project, keyed by project path)
// Whether to default the "delete branch" checkbox when deleting a worktree (default: false)
defaultDeleteBranchByProject: Record<string, boolean>;
// UI State (previously in localStorage, now synced via API) // UI State (previously in localStorage, now synced via API)
/** Whether worktree panel is collapsed in board view */ /** Whether worktree panel is collapsed in board view */
worktreePanelCollapsed: boolean; worktreePanelCollapsed: boolean;
@@ -677,7 +681,7 @@ export interface AppState {
/** Recently accessed folders for quick access */ /** Recently accessed folders for quick access */
recentFolders: string[]; recentFolders: string[];
// Init Script State (per-project, keyed by project path) // Init Script State (keyed by "projectPath::branch" to support concurrent scripts)
initScriptState: Record<string, InitScriptState>; initScriptState: Record<string, InitScriptState>;
} }
@@ -1086,6 +1090,10 @@ export interface AppActions {
setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void; setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void;
getShowInitScriptIndicator: (projectPath: string) => boolean; getShowInitScriptIndicator: (projectPath: string) => boolean;
// Default Delete Branch actions (per-project)
setDefaultDeleteBranch: (projectPath: string, deleteBranch: boolean) => void;
getDefaultDeleteBranch: (projectPath: string) => boolean;
// UI State actions (previously in localStorage, now synced via API) // UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed: boolean) => void; setWorktreePanelCollapsed: (collapsed: boolean) => void;
setLastProjectDir: (dir: string) => void; setLastProjectDir: (dir: string) => void;
@@ -1114,11 +1122,18 @@ export interface AppActions {
}> }>
) => void; ) => void;
// Init Script State actions // Init Script State actions (keyed by projectPath::branch to support concurrent scripts)
setInitScriptState: (projectPath: string, state: Partial<InitScriptState>) => void; setInitScriptState: (
appendInitScriptOutput: (projectPath: string, content: string) => void; projectPath: string,
clearInitScriptState: (projectPath: string) => void; branch: string,
getInitScriptState: (projectPath: string) => InitScriptState | null; state: Partial<InitScriptState>
) => void;
appendInitScriptOutput: (projectPath: string, branch: string, content: string) => void;
clearInitScriptState: (projectPath: string, branch: string) => void;
getInitScriptState: (projectPath: string, branch: string) => InitScriptState | null;
getInitScriptStatesForProject: (
projectPath: string
) => Array<{ key: string; state: InitScriptState }>;
// Reset // Reset
reset: () => void; reset: () => void;
@@ -1217,6 +1232,7 @@ const initialState: AppState = {
pipelineConfigByProject: {}, pipelineConfigByProject: {},
worktreePanelVisibleByProject: {}, worktreePanelVisibleByProject: {},
showInitScriptIndicatorByProject: {}, showInitScriptIndicatorByProject: {},
defaultDeleteBranchByProject: {},
// UI State (previously in localStorage, now synced via API) // UI State (previously in localStorage, now synced via API)
worktreePanelCollapsed: false, worktreePanelCollapsed: false,
lastProjectDir: '', lastProjectDir: '',
@@ -3148,6 +3164,21 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
return get().showInitScriptIndicatorByProject[projectPath] ?? true; return get().showInitScriptIndicatorByProject[projectPath] ?? true;
}, },
// Default Delete Branch actions (per-project)
setDefaultDeleteBranch: (projectPath, deleteBranch) => {
set({
defaultDeleteBranchByProject: {
...get().defaultDeleteBranchByProject,
[projectPath]: deleteBranch,
},
});
},
getDefaultDeleteBranch: (projectPath) => {
// Default to false (don't delete branch) if not set
return get().defaultDeleteBranchByProject[projectPath] ?? false;
},
// UI State actions (previously in localStorage, now synced via API) // UI State actions (previously in localStorage, now synced via API)
setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }), setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }),
setLastProjectDir: (dir) => set({ lastProjectDir: dir }), setLastProjectDir: (dir) => set({ lastProjectDir: dir }),
@@ -3161,28 +3192,30 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
set({ recentFolders: updated }); set({ recentFolders: updated });
}, },
// Init Script State actions // Init Script State actions (keyed by "projectPath::branch")
setInitScriptState: (projectPath, state) => { setInitScriptState: (projectPath, branch, state) => {
const current = get().initScriptState[projectPath] || { const key = `${projectPath}::${branch}`;
const current = get().initScriptState[key] || {
status: 'idle', status: 'idle',
branch: '', branch,
output: [], output: [],
}; };
set({ set({
initScriptState: { initScriptState: {
...get().initScriptState, ...get().initScriptState,
[projectPath]: { ...current, ...state }, [key]: { ...current, ...state },
}, },
}); });
}, },
appendInitScriptOutput: (projectPath, content) => { appendInitScriptOutput: (projectPath, branch, content) => {
const current = get().initScriptState[projectPath]; const key = `${projectPath}::${branch}`;
const current = get().initScriptState[key];
if (!current) return; if (!current) return;
set({ set({
initScriptState: { initScriptState: {
...get().initScriptState, ...get().initScriptState,
[projectPath]: { [key]: {
...current, ...current,
output: [...current.output, content], output: [...current.output, content],
}, },
@@ -3190,13 +3223,23 @@ export const useAppStore = create<AppState & AppActions>()((set, get) => ({
}); });
}, },
clearInitScriptState: (projectPath) => { clearInitScriptState: (projectPath, branch) => {
const { [projectPath]: _, ...rest } = get().initScriptState; const key = `${projectPath}::${branch}`;
const { [key]: _, ...rest } = get().initScriptState;
set({ initScriptState: rest }); set({ initScriptState: rest });
}, },
getInitScriptState: (projectPath) => { getInitScriptState: (projectPath, branch) => {
return get().initScriptState[projectPath] || null; const key = `${projectPath}::${branch}`;
return get().initScriptState[key] || null;
},
getInitScriptStatesForProject: (projectPath) => {
const prefix = `${projectPath}::`;
const states = get().initScriptState;
return Object.entries(states)
.filter(([key]) => key.startsWith(prefix))
.map(([key, state]) => ({ key, state }));
}, },
// Reset // Reset

View File

@@ -598,6 +598,10 @@ export interface ProjectSettings {
/** Whether to show the init script indicator panel (default: true) */ /** Whether to show the init script indicator panel (default: true) */
showInitScriptIndicator?: boolean; showInitScriptIndicator?: boolean;
// Worktree Behavior
/** Default value for "delete branch" checkbox when deleting a worktree (default: false) */
defaultDeleteBranchWithWorktree?: boolean;
// Session Tracking // Session Tracking
/** Last chat session selected in this project */ /** Last chat session selected in this project */
lastSelectedSessionId?: string; lastSelectedSessionId?: string;