diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts b/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts index 37e9ba0b..60e46b9c 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/index.ts @@ -1,5 +1,16 @@ export { BranchSwitchDropdown } from './branch-switch-dropdown'; export { DevServerLogsPanel } from './dev-server-logs-panel'; export { WorktreeActionsDropdown } from './worktree-actions-dropdown'; +export { WorktreeDropdown } from './worktree-dropdown'; +export type { WorktreeDropdownProps } from './worktree-dropdown'; +export { WorktreeDropdownItem } from './worktree-dropdown-item'; +export type { WorktreeDropdownItemProps } from './worktree-dropdown-item'; +export { + truncateBranchName, + getPRBadgeStyles, + getChangesBadgeStyles, + getTestStatusStyles, +} from './worktree-indicator-utils'; +export type { TestStatus } from './worktree-indicator-utils'; export { WorktreeMobileDropdown } from './worktree-mobile-dropdown'; export { WorktreeTab } from './worktree-tab'; diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx new file mode 100644 index 00000000..8549c40b --- /dev/null +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown-item.tsx @@ -0,0 +1,202 @@ +import { DropdownMenuItem } from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Check, CircleDot, Globe, GitPullRequest, FlaskConical } from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { cn } from '@/lib/utils'; +import type { WorktreeInfo, DevServerInfo, TestSessionInfo } from '../types'; +import { + truncateBranchName, + getPRBadgeStyles, + getChangesBadgeStyles, + getTestStatusStyles, +} from './worktree-indicator-utils'; + +/** + * Maximum characters for branch name before truncation in dropdown items. + * Set to 28 to accommodate longer names in the wider dropdown menu while + * still fitting comfortably with all status indicators. + */ +const MAX_ITEM_BRANCH_NAME_LENGTH = 28; + +export interface WorktreeDropdownItemProps { + /** The worktree to display */ + worktree: WorktreeInfo; + /** Whether this worktree is currently selected */ + isSelected: boolean; + /** Whether this worktree has running features/processes */ + isRunning: boolean; + /** Number of cards associated with this worktree's branch */ + cardCount?: number; + /** Whether the dev server is running for this worktree */ + devServerRunning?: boolean; + /** Dev server information if running */ + devServerInfo?: DevServerInfo; + /** Whether auto-mode is running for this worktree */ + isAutoModeRunning?: boolean; + /** Whether tests are running for this worktree */ + isTestRunning?: boolean; + /** Test session info for this worktree */ + testSessionInfo?: TestSessionInfo; + /** Callback when the worktree is selected */ + onSelect: () => void; +} + +/** + * A dropdown menu item component for displaying an individual worktree entry. + * + * Features: + * - Selection indicator (checkmark when selected) + * - Running status indicator (spinner) + * - Branch name with tooltip for long names + * - Main branch badge + * - Dev server status indicator + * - Auto mode indicator + * - Test status indicator + * - Card count badge + * - Uncommitted changes indicator + * - PR status badge + */ +export function WorktreeDropdownItem({ + worktree, + isSelected, + isRunning, + cardCount, + devServerRunning, + devServerInfo, + isAutoModeRunning = false, + isTestRunning = false, + testSessionInfo, + onSelect, +}: WorktreeDropdownItemProps) { + const { hasChanges, changedFilesCount, pr } = worktree; + + // Truncate long branch names using shared utility + const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName( + worktree.branch, + MAX_ITEM_BRANCH_NAME_LENGTH + ); + + const branchNameElement = ( + + {truncatedBranch} + + ); + + return ( + +
+ {/* Selection indicator */} + {isSelected ? ( + + ) : ( +
+ )} + + {/* Running indicator */} + {isRunning && } + + {/* Branch name with optional tooltip */} + {isBranchNameTruncated ? ( + + + {branchNameElement} + +

{worktree.branch}

+
+
+
+ ) : ( + branchNameElement + )} + + {/* Main badge */} + {worktree.isMain && ( + + main + + )} +
+ + {/* Right side indicators - ordered consistently with dropdown trigger */} +
+ {/* Card count badge */} + {cardCount !== undefined && cardCount > 0 && ( + + {cardCount} + + )} + + {/* Uncommitted changes indicator */} + {hasChanges && ( + + + {changedFilesCount ?? '!'} + + )} + + {/* Dev server indicator */} + {devServerRunning && ( + + + + )} + + {/* Test running indicator */} + {isTestRunning && ( + + + + )} + + {/* Last test result indicator (when not running) */} + {!isTestRunning && testSessionInfo && ( + + + + )} + + {/* Auto mode indicator */} + {isAutoModeRunning && ( + + + + )} + + {/* PR indicator */} + {pr && ( + + #{pr.number} + + )} +
+ + ); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx new file mode 100644 index 00000000..c2bf8ac5 --- /dev/null +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-dropdown.tsx @@ -0,0 +1,481 @@ +import { useMemo } from 'react'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuGroup, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + GitBranch, + ChevronDown, + CircleDot, + Globe, + GitPullRequest, + FlaskConical, +} from 'lucide-react'; +import { Spinner } from '@/components/ui/spinner'; +import { cn } from '@/lib/utils'; +import type { + WorktreeInfo, + BranchInfo, + DevServerInfo, + PRInfo, + GitRepoStatus, + TestSessionInfo, +} from '../types'; +import { WorktreeDropdownItem } from './worktree-dropdown-item'; +import { BranchSwitchDropdown } from './branch-switch-dropdown'; +import { WorktreeActionsDropdown } from './worktree-actions-dropdown'; +import { + truncateBranchName, + getPRBadgeStyles, + getChangesBadgeStyles, + getTestStatusStyles, +} from './worktree-indicator-utils'; + +export interface WorktreeDropdownProps { + /** List of all worktrees to display in the dropdown */ + worktrees: WorktreeInfo[]; + /** Function to check if a worktree is currently selected */ + isWorktreeSelected: (worktree: WorktreeInfo) => boolean; + /** Function to check if a worktree has running features/processes */ + hasRunningFeatures: (worktree: WorktreeInfo) => boolean; + /** Whether worktree activation is in progress */ + isActivating: boolean; + /** Map of branch names to card counts */ + branchCardCounts?: Record; + /** Function to check if dev server is running for a worktree */ + isDevServerRunning: (worktree: WorktreeInfo) => boolean; + /** Function to get dev server info for a worktree */ + getDevServerInfo: (worktree: WorktreeInfo) => DevServerInfo | undefined; + /** Function to check if auto-mode is running for a worktree */ + isAutoModeRunningForWorktree: (worktree: WorktreeInfo) => boolean; + /** Function to check if tests are running for a worktree */ + isTestRunningForWorktree: (worktree: WorktreeInfo) => boolean; + /** Function to get test session info for a worktree */ + getTestSessionInfo: (worktree: WorktreeInfo) => TestSessionInfo | undefined; + /** Callback when a worktree is selected */ + onSelectWorktree: (worktree: WorktreeInfo) => void; + + // Branch switching props + branches: BranchInfo[]; + filteredBranches: BranchInfo[]; + branchFilter: string; + isLoadingBranches: boolean; + isSwitching: boolean; + onBranchDropdownOpenChange: (worktree: WorktreeInfo) => (open: boolean) => void; + onBranchFilterChange: (value: string) => void; + onSwitchBranch: (worktree: WorktreeInfo, branchName: string) => void; + onCreateBranch: (worktree: WorktreeInfo) => void; + + // Action dropdown props + isPulling: boolean; + isPushing: boolean; + isStartingDevServer: boolean; + aheadCount: number; + behindCount: number; + hasRemoteBranch: boolean; + gitRepoStatus: GitRepoStatus; + hasTestCommand: boolean; + isStartingTests: boolean; + hasInitScript: boolean; + onActionsDropdownOpenChange: (worktree: WorktreeInfo) => (open: boolean) => void; + onPull: (worktree: WorktreeInfo) => void; + onPush: (worktree: WorktreeInfo) => void; + onPushNewBranch: (worktree: WorktreeInfo) => void; + onOpenInEditor: (worktree: WorktreeInfo, editorCommand?: string) => void; + onOpenInIntegratedTerminal: (worktree: WorktreeInfo, mode?: 'tab' | 'split') => void; + onOpenInExternalTerminal: (worktree: WorktreeInfo, terminalId?: string) => void; + onViewChanges: (worktree: WorktreeInfo) => void; + onDiscardChanges: (worktree: WorktreeInfo) => void; + onCommit: (worktree: WorktreeInfo) => void; + onCreatePR: (worktree: WorktreeInfo) => void; + onAddressPRComments: (worktree: WorktreeInfo, prInfo: PRInfo) => void; + onResolveConflicts: (worktree: WorktreeInfo) => void; + onMerge: (worktree: WorktreeInfo) => void; + onDeleteWorktree: (worktree: WorktreeInfo) => void; + onStartDevServer: (worktree: WorktreeInfo) => void; + onStopDevServer: (worktree: WorktreeInfo) => void; + onOpenDevServerUrl: (worktree: WorktreeInfo) => void; + onViewDevServerLogs: (worktree: WorktreeInfo) => void; + onRunInitScript: (worktree: WorktreeInfo) => void; + onToggleAutoMode: (worktree: WorktreeInfo) => void; + onStartTests: (worktree: WorktreeInfo) => void; + onStopTests: (worktree: WorktreeInfo) => void; + onViewTestLogs: (worktree: WorktreeInfo) => void; +} + +/** + * Maximum characters for branch name before truncation in the dropdown trigger. + * Set to 24 to keep the trigger compact while showing enough context for identification. + */ +const MAX_TRIGGER_BRANCH_NAME_LENGTH = 24; + +/** + * A dropdown component for displaying and switching between worktrees. + * Used when there are 3+ worktrees to avoid horizontal tab wrapping. + * + * Features: + * - Compact dropdown trigger showing current worktree with indicators + * - Grouped display (main branch + worktrees) + * - Full status indicators (PR, dev server, auto mode, changes) + * - Branch switch dropdown integration + * - Actions dropdown integration + * - Tooltip for truncated branch names + */ +export function WorktreeDropdown({ + worktrees, + isWorktreeSelected, + hasRunningFeatures, + isActivating, + branchCardCounts, + isDevServerRunning, + getDevServerInfo, + isAutoModeRunningForWorktree, + isTestRunningForWorktree, + getTestSessionInfo, + onSelectWorktree, + // Branch switching props + branches, + filteredBranches, + branchFilter, + isLoadingBranches, + isSwitching, + onBranchDropdownOpenChange, + onBranchFilterChange, + onSwitchBranch, + onCreateBranch, + // Action dropdown props + isPulling, + isPushing, + isStartingDevServer, + aheadCount, + behindCount, + hasRemoteBranch, + gitRepoStatus, + hasTestCommand, + isStartingTests, + hasInitScript, + onActionsDropdownOpenChange, + onPull, + onPush, + onPushNewBranch, + onOpenInEditor, + onOpenInIntegratedTerminal, + onOpenInExternalTerminal, + onViewChanges, + onDiscardChanges, + onCommit, + onCreatePR, + onAddressPRComments, + onResolveConflicts, + onMerge, + onDeleteWorktree, + onStartDevServer, + onStopDevServer, + onOpenDevServerUrl, + onViewDevServerLogs, + onRunInitScript, + onToggleAutoMode, + onStartTests, + onStopTests, + onViewTestLogs, +}: WorktreeDropdownProps) { + // Find the currently selected worktree to display in the trigger + const selectedWorktree = worktrees.find((w) => isWorktreeSelected(w)); + const displayBranch = selectedWorktree?.branch || 'Select worktree'; + const { truncated: truncatedBranch, isTruncated: isBranchNameTruncated } = truncateBranchName( + displayBranch, + MAX_TRIGGER_BRANCH_NAME_LENGTH + ); + + // Separate main worktree from others for grouping + const mainWorktree = worktrees.find((w) => w.isMain); + const otherWorktrees = worktrees.filter((w) => !w.isMain); + + // Get status info for selected worktree - memoized to prevent unnecessary recalculations + const selectedStatus = useMemo(() => { + if (!selectedWorktree) { + return { + devServerRunning: false, + devServerInfo: undefined, + autoModeRunning: false, + isRunning: false, + testRunning: false, + testSessionInfo: undefined, + }; + } + return { + devServerRunning: isDevServerRunning(selectedWorktree), + devServerInfo: getDevServerInfo(selectedWorktree), + autoModeRunning: isAutoModeRunningForWorktree(selectedWorktree), + isRunning: hasRunningFeatures(selectedWorktree), + testRunning: isTestRunningForWorktree(selectedWorktree), + testSessionInfo: getTestSessionInfo(selectedWorktree), + }; + }, [ + selectedWorktree, + isDevServerRunning, + getDevServerInfo, + isAutoModeRunningForWorktree, + hasRunningFeatures, + isTestRunningForWorktree, + getTestSessionInfo, + ]); + + // Build trigger button with all indicators - memoized for performance + const triggerButton = useMemo( + () => ( + + ), + [isActivating, selectedStatus, truncatedBranch, selectedWorktree, branchCardCounts] + ); + + // Wrap trigger button with dropdown trigger first to ensure ref is passed correctly + const dropdownTrigger = {triggerButton}; + + const triggerWithTooltip = isBranchNameTruncated ? ( + + + {dropdownTrigger} + +

{displayBranch}

+
+
+
+ ) : ( + dropdownTrigger + ); + + return ( +
+ + {triggerWithTooltip} + + {/* Main worktree section */} + {mainWorktree && ( + <> + + Main Branch + + onSelectWorktree(mainWorktree)} + /> + + )} + + {/* Other worktrees section */} + {otherWorktrees.length > 0 && ( + <> + + + Worktrees ({otherWorktrees.length}) + + + {otherWorktrees.map((worktree) => ( + onSelectWorktree(worktree)} + /> + ))} + + + )} + + {/* Empty state */} + {worktrees.length === 0 && ( +
+ No worktrees available +
+ )} +
+
+ + {/* Branch switch dropdown for main branch (only when main is selected) */} + {selectedWorktree?.isMain && ( + + )} + + {/* Actions dropdown for the selected worktree */} + {selectedWorktree && ( + + )} +
+ ); +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-indicator-utils.ts b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-indicator-utils.ts new file mode 100644 index 00000000..503a8396 --- /dev/null +++ b/apps/ui/src/components/views/board-view/worktree-panel/components/worktree-indicator-utils.ts @@ -0,0 +1,70 @@ +/** + * Shared utility functions for worktree indicator styling and formatting. + * These utilities ensure consistent appearance across WorktreeTab, WorktreeDropdown, + * and WorktreeDropdownItem components. + */ + +import type { PRInfo } from '../types'; + +/** + * Truncates a branch name if it exceeds the maximum length. + * @param branchName - The full branch name + * @param maxLength - Maximum characters before truncation + * @returns Object with truncated name and whether truncation occurred + */ +export function truncateBranchName( + branchName: string, + maxLength: number +): { truncated: string; isTruncated: boolean } { + const isTruncated = branchName.length > maxLength; + const truncated = isTruncated ? `${branchName.slice(0, maxLength)}...` : branchName; + return { truncated, isTruncated }; +} + +/** + * Returns the appropriate CSS classes for a PR badge based on PR state. + * @param state - The PR state (OPEN, MERGED, or CLOSED) + * @returns CSS class string for the badge + */ +export function getPRBadgeStyles(state: PRInfo['state']): string { + switch (state) { + case 'OPEN': + return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'; + case 'MERGED': + return 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30'; + case 'CLOSED': + default: + return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'; + } +} + +/** + * Returns the CSS classes for the uncommitted changes badge. + * This is a constant style used across all worktree components. + */ +export function getChangesBadgeStyles(): string { + return 'bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30'; +} + +/** Possible test session status values */ +export type TestStatus = 'pending' | 'running' | 'passed' | 'failed' | 'cancelled'; + +/** + * Returns the CSS classes for a test status indicator based on test result. + * @param status - The test session status + * @returns CSS class string for the indicator color + */ +export function getTestStatusStyles(status: TestStatus): string { + switch (status) { + case 'passed': + return 'text-green-500'; + case 'failed': + return 'text-red-500'; + case 'running': + return 'text-blue-500'; + case 'pending': + case 'cancelled': + default: + return 'text-muted-foreground'; + } +} diff --git a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx index 40f10e85..b1e800fe 100644 --- a/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx +++ b/apps/ui/src/components/views/board-view/worktree-panel/worktree-panel.tsx @@ -28,6 +28,7 @@ import { WorktreeMobileDropdown, WorktreeActionsDropdown, BranchSwitchDropdown, + WorktreeDropdown, } from './components'; import { useAppStore } from '@/store/app-store'; import { ViewWorktreeChangesDialog, PushToRemoteDialog, MergeWorktreeDialog } from '../dialogs'; @@ -36,6 +37,9 @@ import { TestLogsPanel } from '@/components/ui/test-logs-panel'; import { Undo2 } from 'lucide-react'; import { getElectronAPI } from '@/lib/electron'; +/** Threshold for switching from tabs to dropdown layout (number of worktrees) */ +const WORKTREE_DROPDOWN_THRESHOLD = 3; + export function WorktreePanel({ projectPath, onCreateWorktree, @@ -712,30 +716,43 @@ export function WorktreePanel({ ); } - // Desktop view: full tabs layout + // Use dropdown layout when worktree count meets or exceeds the threshold + const useDropdownLayout = worktrees.length >= WORKTREE_DROPDOWN_THRESHOLD; + + // Desktop view: full tabs layout or dropdown layout depending on worktree count return (
- Branch: + + {useDropdownLayout ? 'Worktree:' : 'Branch:'} + -
- {mainWorktree && ( - + - )} -
- {/* Worktrees section - only show if enabled */} - {useWorktreesEnabled && ( + {useWorktreesEnabled && ( + <> + + + + + )} + + ) : ( + /* Standard tabs layout for 1-2 worktrees */ <> -
- - Worktrees: - -
- {nonMainWorktrees.map((worktree) => { - const cardCount = branchCardCounts?.[worktree.branch]; - return ( - - ); - })} - - - - +
+ {mainWorktree && ( + + )}
+ + {/* Worktrees section - only show if enabled and not using dropdown layout */} + {useWorktreesEnabled && ( + <> +
+ + Worktrees: + +
+ {nonMainWorktrees.map((worktree) => { + const cardCount = branchCardCounts?.[worktree.branch]; + return ( + + ); + })} + + + + +
+ + )} )}