From e902e8ea4cf74e8721ab0ffe3e89721df7340395 Mon Sep 17 00:00:00 2001 From: Kacper Date: Sat, 10 Jan 2026 23:18:39 +0100 Subject: [PATCH] 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. --- apps/ui/src/components/views/board-view.tsx | 2 + .../dialogs/delete-worktree-dialog.tsx | 14 +- .../board-view/init-script-indicator.tsx | 127 ++++++++++++------ .../worktrees/worktrees-section.tsx | 35 +++++ apps/ui/src/hooks/use-init-script-events.ts | 6 +- apps/ui/src/store/app-store.ts | 79 ++++++++--- libs/types/src/settings.ts | 4 + 7 files changed, 204 insertions(+), 63 deletions(-) diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 028f7bd1..683c46c5 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -119,6 +119,7 @@ export function BoardView() { (state) => state.showInitScriptIndicatorByProject ); const getShowInitScriptIndicator = useAppStore((state) => state.getShowInitScriptIndicator); + const getDefaultDeleteBranch = useAppStore((state) => state.getDefaultDeleteBranch); const shortcuts = useKeyboardShortcutsConfig(); const { features: hookFeatures, @@ -1513,6 +1514,7 @@ export function BoardView() { ? hookFeatures.filter((f) => f.branchName === selectedWorktreeForAction.branch).length : 0 } + defaultDeleteBranch={getDefaultDeleteBranch(currentProject.path)} onDeleted={(deletedWorktree, _deletedBranch) => { // Reset features that were assigned to the deleted worktree (by branch) hookFeatures.forEach((feature) => { diff --git a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx index 3c45c014..718bef0c 100644 --- a/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/delete-worktree-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Dialog, DialogContent, @@ -30,6 +30,8 @@ interface DeleteWorktreeDialogProps { onDeleted: (deletedWorktree: WorktreeInfo, deletedBranch: boolean) => void; /** Number of features assigned to this worktree's branch */ affectedFeatureCount?: number; + /** Default value for the "delete branch" checkbox */ + defaultDeleteBranch?: boolean; } export function DeleteWorktreeDialog({ @@ -39,10 +41,18 @@ export function DeleteWorktreeDialog({ worktree, onDeleted, affectedFeatureCount = 0, + defaultDeleteBranch = false, }: DeleteWorktreeDialogProps) { - const [deleteBranch, setDeleteBranch] = useState(false); + const [deleteBranch, setDeleteBranch] = useState(defaultDeleteBranch); const [isLoading, setIsLoading] = useState(false); + // Reset deleteBranch to default when dialog opens + useEffect(() => { + if (open) { + setDeleteBranch(defaultDeleteBranch); + } + }, [open, defaultDeleteBranch]); + const handleDelete = async () => { if (!worktree) return; diff --git a/apps/ui/src/components/views/board-view/init-script-indicator.tsx b/apps/ui/src/components/views/board-view/init-script-indicator.tsx index ca108dbd..a039f58b 100644 --- a/apps/ui/src/components/views/board-view/init-script-indicator.tsx +++ b/apps/ui/src/components/views/board-view/init-script-indicator.tsx @@ -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 { cn } from '@/lib/utils'; import { useAppStore, type InitScriptState } from '@/store/app-store'; @@ -8,45 +8,38 @@ interface InitScriptIndicatorProps { projectPath: string; } -export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { - const initScriptState = useAppStore((s) => s.initScriptState[projectPath]); - const clearInitScriptState = useAppStore((s) => s.clearInitScriptState); +interface SingleIndicatorProps { + stateKey: string; + 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 [dismissed, setDismissed] = useState(false); const logsEndRef = useRef(null); + const { status, output, branch, error } = state; + // Auto-scroll to bottom when new output arrives useEffect(() => { if (showLogs && logsEndRef.current) { 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(() => { - if (initScriptState?.status === 'running') { - setDismissed(false); + if (status === 'running' && isOnlyOne) { setShowLogs(true); } - }, [initScriptState?.status]); + }, [status, isOnlyOne]); - if (!initScriptState || dismissed) 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); - }; + if (status === 'idle') return null; return (
- {status === 'running' && ( - - )} + {status === 'running' && } {status === 'success' && } {status === 'failed' && } Init Script{' '} - {status === 'running' - ? 'Running' - : status === 'success' - ? 'Completed' - : 'Failed'} + {status === 'running' ? 'Running' : status === 'success' ? 'Completed' : 'Failed'}
@@ -83,7 +70,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { {status !== 'running' && (
)} - {error && ( -
- Error: {error} -
- )} + {error &&
Error: {error}
}
@@ -125,9 +108,7 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
{status === 'success' @@ -138,3 +119,69 @@ export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) {
); } + +export function InitScriptIndicator({ projectPath }: InitScriptIndicatorProps) { + const getInitScriptStatesForProject = useAppStore((s) => s.getInitScriptStatesForProject); + const clearInitScriptState = useAppStore((s) => s.clearInitScriptState); + const [dismissedKeys, setDismissedKeys] = useState>(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 ( +
+ {activeStates.map(({ key, state }) => ( + + ))} +
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx index 01ff77c6..4a281995 100644 --- a/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx +++ b/apps/ui/src/components/views/settings-view/worktrees/worktrees-section.tsx @@ -35,6 +35,8 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre const currentProject = useAppStore((s) => s.currentProject); const getShowInitScriptIndicator = useAppStore((s) => s.getShowInitScriptIndicator); const setShowInitScriptIndicator = useAppStore((s) => s.setShowInitScriptIndicator); + const getDefaultDeleteBranch = useAppStore((s) => s.getDefaultDeleteBranch); + const setDefaultDeleteBranch = useAppStore((s) => s.setDefaultDeleteBranch); const [scriptContent, setScriptContent] = useState(''); const [originalContent, setOriginalContent] = useState(''); const [scriptExists, setScriptExists] = useState(false); @@ -47,6 +49,11 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre ? getShowInitScriptIndicator(currentProject.path) : true; + // Get the default delete branch setting + const defaultDeleteBranch = currentProject?.path + ? getDefaultDeleteBranch(currentProject.path) + : false; + // Check if there are unsaved changes const hasChanges = scriptContent !== originalContent; @@ -226,6 +233,34 @@ export function WorktreesSection({ useWorktrees, onUseWorktreesChange }: Worktre )} + {/* Default Delete Branch Toggle */} + {currentProject && ( +
+ { + if (currentProject?.path) { + setDefaultDeleteBranch(currentProject.path, checked === true); + } + }} + className="mt-1" + /> +
+ +

+ When deleting a worktree, automatically check the "Also delete the branch" option. +

+
+
+ )} + {/* Separator */}
diff --git a/apps/ui/src/hooks/use-init-script-events.ts b/apps/ui/src/hooks/use-init-script-events.ts index b95b485d..aa51409f 100644 --- a/apps/ui/src/hooks/use-init-script-events.ts +++ b/apps/ui/src/hooks/use-init-script-events.ts @@ -50,7 +50,7 @@ export function useInitScriptEvents(projectPath: string | null) { switch (event.type) { case 'worktree:init-started': { const startPayload = payload as InitScriptStartedPayload; - setInitScriptState(projectPath, { + setInitScriptState(projectPath, startPayload.branch, { status: 'running', branch: startPayload.branch, output: [], @@ -60,12 +60,12 @@ export function useInitScriptEvents(projectPath: string | null) { } case 'worktree:init-output': { const outputPayload = payload as InitScriptOutputPayload; - appendInitScriptOutput(projectPath, outputPayload.content); + appendInitScriptOutput(projectPath, outputPayload.branch, outputPayload.content); break; } case 'worktree:init-completed': { const completePayload = payload as InitScriptCompletedPayload; - setInitScriptState(projectPath, { + setInitScriptState(projectPath, completePayload.branch, { status: completePayload.success ? 'success' : 'failed', error: completePayload.error, }); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 974ffe87..2d9f42da 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -669,6 +669,10 @@ export interface AppState { // Whether to show the floating init script indicator panel (default: true) showInitScriptIndicatorByProject: Record; + // 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; + // UI State (previously in localStorage, now synced via API) /** Whether worktree panel is collapsed in board view */ worktreePanelCollapsed: boolean; @@ -677,7 +681,7 @@ export interface AppState { /** Recently accessed folders for quick access */ recentFolders: string[]; - // Init Script State (per-project, keyed by project path) + // Init Script State (keyed by "projectPath::branch" to support concurrent scripts) initScriptState: Record; } @@ -1086,6 +1090,10 @@ export interface AppActions { setShowInitScriptIndicator: (projectPath: string, visible: boolean) => void; 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) setWorktreePanelCollapsed: (collapsed: boolean) => void; setLastProjectDir: (dir: string) => void; @@ -1114,11 +1122,18 @@ export interface AppActions { }> ) => void; - // Init Script State actions - setInitScriptState: (projectPath: string, state: Partial) => void; - appendInitScriptOutput: (projectPath: string, content: string) => void; - clearInitScriptState: (projectPath: string) => void; - getInitScriptState: (projectPath: string) => InitScriptState | null; + // Init Script State actions (keyed by projectPath::branch to support concurrent scripts) + setInitScriptState: ( + projectPath: string, + branch: string, + state: Partial + ) => 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: () => void; @@ -1217,6 +1232,7 @@ const initialState: AppState = { pipelineConfigByProject: {}, worktreePanelVisibleByProject: {}, showInitScriptIndicatorByProject: {}, + defaultDeleteBranchByProject: {}, // UI State (previously in localStorage, now synced via API) worktreePanelCollapsed: false, lastProjectDir: '', @@ -3148,6 +3164,21 @@ export const useAppStore = create()((set, get) => ({ 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) setWorktreePanelCollapsed: (collapsed) => set({ worktreePanelCollapsed: collapsed }), setLastProjectDir: (dir) => set({ lastProjectDir: dir }), @@ -3161,28 +3192,30 @@ export const useAppStore = create()((set, get) => ({ set({ recentFolders: updated }); }, - // Init Script State actions - setInitScriptState: (projectPath, state) => { - const current = get().initScriptState[projectPath] || { + // Init Script State actions (keyed by "projectPath::branch") + setInitScriptState: (projectPath, branch, state) => { + const key = `${projectPath}::${branch}`; + const current = get().initScriptState[key] || { status: 'idle', - branch: '', + branch, output: [], }; set({ initScriptState: { ...get().initScriptState, - [projectPath]: { ...current, ...state }, + [key]: { ...current, ...state }, }, }); }, - appendInitScriptOutput: (projectPath, content) => { - const current = get().initScriptState[projectPath]; + appendInitScriptOutput: (projectPath, branch, content) => { + const key = `${projectPath}::${branch}`; + const current = get().initScriptState[key]; if (!current) return; set({ initScriptState: { ...get().initScriptState, - [projectPath]: { + [key]: { ...current, output: [...current.output, content], }, @@ -3190,13 +3223,23 @@ export const useAppStore = create()((set, get) => ({ }); }, - clearInitScriptState: (projectPath) => { - const { [projectPath]: _, ...rest } = get().initScriptState; + clearInitScriptState: (projectPath, branch) => { + const key = `${projectPath}::${branch}`; + const { [key]: _, ...rest } = get().initScriptState; set({ initScriptState: rest }); }, - getInitScriptState: (projectPath) => { - return get().initScriptState[projectPath] || null; + getInitScriptState: (projectPath, branch) => { + 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 diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 7a46d4aa..1b8f37d4 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -598,6 +598,10 @@ export interface ProjectSettings { /** Whether to show the init script indicator panel (default: true) */ showInitScriptIndicator?: boolean; + // Worktree Behavior + /** Default value for "delete branch" checkbox when deleting a worktree (default: false) */ + defaultDeleteBranchWithWorktree?: boolean; + // Session Tracking /** Last chat session selected in this project */ lastSelectedSessionId?: string;