mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge pull request #680 from AutoMaker-Org/feature/bug-improve-the-worktree-ui-79ph
fix(ui): Improve worktree panel UI with dropdown for multiple worktrees
This commit is contained in:
@@ -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';
|
||||
|
||||
@@ -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 = (
|
||||
<span className={cn('font-mono text-xs truncate', isSelected && 'font-medium')}>
|
||||
{truncatedBranch}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onSelect={onSelect}
|
||||
className={cn('flex items-center gap-2 cursor-pointer pr-2', isSelected && 'bg-accent')}
|
||||
aria-current={isSelected ? 'true' : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* Selection indicator */}
|
||||
{isSelected ? (
|
||||
<Check className="w-3.5 h-3.5 shrink-0 text-primary" />
|
||||
) : (
|
||||
<div className="w-3.5 h-3.5 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Running indicator */}
|
||||
{isRunning && <Spinner size="xs" className="shrink-0" />}
|
||||
|
||||
{/* Branch name with optional tooltip */}
|
||||
{isBranchNameTruncated ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{branchNameElement}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-mono text-xs">{worktree.branch}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
branchNameElement
|
||||
)}
|
||||
|
||||
{/* Main badge */}
|
||||
{worktree.isMain && (
|
||||
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||
main
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side indicators - ordered consistently with dropdown trigger */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* Card count badge */}
|
||||
{cardCount !== undefined && cardCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border">
|
||||
{cardCount}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Uncommitted changes indicator */}
|
||||
{hasChanges && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-[1rem] px-1 text-[10px] font-medium rounded border',
|
||||
getChangesBadgeStyles()
|
||||
)}
|
||||
title={`${changedFilesCount ?? 'Some'} uncommitted file${changedFilesCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||
{changedFilesCount ?? '!'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dev server indicator */}
|
||||
{devServerRunning && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500"
|
||||
title={`Dev server running on port ${devServerInfo?.port}`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Test running indicator */}
|
||||
{isTestRunning && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-blue-500"
|
||||
title="Tests Running"
|
||||
>
|
||||
<FlaskConical className="w-3 h-3 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Last test result indicator (when not running) */}
|
||||
{!isTestRunning && testSessionInfo && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 w-4',
|
||||
getTestStatusStyles(testSessionInfo.status)
|
||||
)}
|
||||
title={`Last test: ${testSessionInfo.status}`}
|
||||
>
|
||||
<FlaskConical className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Auto mode indicator */}
|
||||
{isAutoModeRunning && (
|
||||
<span className="flex items-center justify-center h-4 px-0.5" title="Auto Mode Running">
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* PR indicator */}
|
||||
{pr && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 h-4 px-1 text-[10px] font-medium rounded border',
|
||||
getPRBadgeStyles(pr.state)
|
||||
)}
|
||||
title={`PR #${pr.number}: ${pr.title}`}
|
||||
>
|
||||
<GitPullRequest className="w-2.5 h-2.5" />#{pr.number}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
/** 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(
|
||||
() => (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'h-7 px-3 gap-1.5 font-mono text-xs bg-secondary/50 hover:bg-secondary min-w-0 border-r-0 rounded-r-none'
|
||||
)}
|
||||
disabled={isActivating}
|
||||
>
|
||||
{/* Running/Activating indicator */}
|
||||
{(selectedStatus.isRunning || isActivating) && <Spinner size="xs" className="shrink-0" />}
|
||||
|
||||
{/* Branch icon */}
|
||||
<GitBranch className="w-3.5 h-3.5 shrink-0" />
|
||||
|
||||
{/* Branch name with optional tooltip */}
|
||||
<span className="truncate max-w-[150px]">{truncatedBranch}</span>
|
||||
|
||||
{/* Card count badge */}
|
||||
{selectedWorktree &&
|
||||
branchCardCounts?.[selectedWorktree.branch] !== undefined &&
|
||||
branchCardCounts[selectedWorktree.branch] > 0 && (
|
||||
<span className="inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded bg-background/80 text-foreground border border-border shrink-0">
|
||||
{branchCardCounts[selectedWorktree.branch]}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Uncommitted changes indicator */}
|
||||
{selectedWorktree?.hasChanges && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 min-w-4 px-1 text-[10px] font-medium rounded border shrink-0',
|
||||
getChangesBadgeStyles()
|
||||
)}
|
||||
>
|
||||
<CircleDot className="w-2.5 h-2.5 mr-0.5" />
|
||||
{selectedWorktree.changedFilesCount ?? '!'}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dev server indicator */}
|
||||
{selectedStatus.devServerRunning && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-green-500 shrink-0"
|
||||
title={`Dev server running on port ${selectedStatus.devServerInfo?.port}`}
|
||||
>
|
||||
<Globe className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Test running indicator */}
|
||||
{selectedStatus.testRunning && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-4 w-4 text-blue-500 shrink-0"
|
||||
title="Tests Running"
|
||||
>
|
||||
<FlaskConical className="w-3 h-3 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Last test result indicator (when not running) */}
|
||||
{!selectedStatus.testRunning && selectedStatus.testSessionInfo && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center h-4 w-4 shrink-0',
|
||||
getTestStatusStyles(selectedStatus.testSessionInfo.status)
|
||||
)}
|
||||
title={`Last test: ${selectedStatus.testSessionInfo.status}`}
|
||||
>
|
||||
<FlaskConical className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Auto mode indicator */}
|
||||
{selectedStatus.autoModeRunning && (
|
||||
<span
|
||||
className="flex items-center justify-center h-4 px-0.5 shrink-0"
|
||||
title="Auto Mode Running"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-green-500 animate-pulse" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* PR badge */}
|
||||
{selectedWorktree?.pr && (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-0.5 h-4 px-1 text-[10px] font-medium rounded border shrink-0',
|
||||
getPRBadgeStyles(selectedWorktree.pr.state)
|
||||
)}
|
||||
>
|
||||
<GitPullRequest className="w-2.5 h-2.5" />#{selectedWorktree.pr.number}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Dropdown chevron */}
|
||||
<ChevronDown className="w-3 h-3 shrink-0 ml-auto" />
|
||||
</Button>
|
||||
),
|
||||
[isActivating, selectedStatus, truncatedBranch, selectedWorktree, branchCardCounts]
|
||||
);
|
||||
|
||||
// Wrap trigger button with dropdown trigger first to ensure ref is passed correctly
|
||||
const dropdownTrigger = <DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>;
|
||||
|
||||
const triggerWithTooltip = isBranchNameTruncated ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{dropdownTrigger}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="font-mono text-xs">{displayBranch}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
dropdownTrigger
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<DropdownMenu>
|
||||
{triggerWithTooltip}
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-80 max-h-96 overflow-y-auto"
|
||||
aria-label="Worktree selection"
|
||||
>
|
||||
{/* Main worktree section */}
|
||||
{mainWorktree && (
|
||||
<>
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Main Branch
|
||||
</DropdownMenuLabel>
|
||||
<WorktreeDropdownItem
|
||||
worktree={mainWorktree}
|
||||
isSelected={isWorktreeSelected(mainWorktree)}
|
||||
isRunning={hasRunningFeatures(mainWorktree)}
|
||||
cardCount={branchCardCounts?.[mainWorktree.branch]}
|
||||
devServerRunning={isDevServerRunning(mainWorktree)}
|
||||
devServerInfo={getDevServerInfo(mainWorktree)}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(mainWorktree)}
|
||||
isTestRunning={isTestRunningForWorktree(mainWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(mainWorktree)}
|
||||
onSelect={() => onSelectWorktree(mainWorktree)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Other worktrees section */}
|
||||
{otherWorktrees.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
|
||||
Worktrees ({otherWorktrees.length})
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuGroup>
|
||||
{otherWorktrees.map((worktree) => (
|
||||
<WorktreeDropdownItem
|
||||
key={worktree.path}
|
||||
worktree={worktree}
|
||||
isSelected={isWorktreeSelected(worktree)}
|
||||
isRunning={hasRunningFeatures(worktree)}
|
||||
cardCount={branchCardCounts?.[worktree.branch]}
|
||||
devServerRunning={isDevServerRunning(worktree)}
|
||||
devServerInfo={getDevServerInfo(worktree)}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(worktree)}
|
||||
isTestRunning={isTestRunningForWorktree(worktree)}
|
||||
testSessionInfo={getTestSessionInfo(worktree)}
|
||||
onSelect={() => onSelectWorktree(worktree)}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{worktrees.length === 0 && (
|
||||
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||
No worktrees available
|
||||
</div>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Branch switch dropdown for main branch (only when main is selected) */}
|
||||
{selectedWorktree?.isMain && (
|
||||
<BranchSwitchDropdown
|
||||
worktree={selectedWorktree}
|
||||
isSelected={true}
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onOpenChange={onBranchDropdownOpenChange(selectedWorktree)}
|
||||
onFilterChange={onBranchFilterChange}
|
||||
onSwitchBranch={onSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions dropdown for the selected worktree */}
|
||||
{selectedWorktree && (
|
||||
<WorktreeActionsDropdown
|
||||
worktree={selectedWorktree}
|
||||
isSelected={true}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
isDevServerRunning={isDevServerRunning(selectedWorktree)}
|
||||
devServerInfo={getDevServerInfo(selectedWorktree)}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
isAutoModeRunning={isAutoModeRunningForWorktree(selectedWorktree)}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
isTestRunning={isTestRunningForWorktree(selectedWorktree)}
|
||||
testSessionInfo={getTestSessionInfo(selectedWorktree)}
|
||||
onOpenChange={onActionsDropdownOpenChange(selectedWorktree)}
|
||||
onPull={onPull}
|
||||
onPush={onPush}
|
||||
onPushNewBranch={onPushNewBranch}
|
||||
onOpenInEditor={onOpenInEditor}
|
||||
onOpenInIntegratedTerminal={onOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={onOpenInExternalTerminal}
|
||||
onViewChanges={onViewChanges}
|
||||
onDiscardChanges={onDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={onMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={onStartDevServer}
|
||||
onStopDevServer={onStopDevServer}
|
||||
onOpenDevServerUrl={onOpenDevServerUrl}
|
||||
onViewDevServerLogs={onViewDevServerLogs}
|
||||
onRunInitScript={onRunInitScript}
|
||||
onToggleAutoMode={onToggleAutoMode}
|
||||
onStartTests={onStartTests}
|
||||
onStopTests={onStopTests}
|
||||
onViewTestLogs={onViewTestLogs}
|
||||
hasInitScript={hasInitScript}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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,12 +716,112 @@ 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 (
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-glass/50 backdrop-blur-sm">
|
||||
<GitBranch className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground mr-2">Branch:</span>
|
||||
<span className="text-sm text-muted-foreground mr-2">
|
||||
{useDropdownLayout ? 'Worktree:' : 'Branch:'}
|
||||
</span>
|
||||
|
||||
{/* Dropdown layout for 3+ worktrees */}
|
||||
{useDropdownLayout ? (
|
||||
<>
|
||||
<WorktreeDropdown
|
||||
worktrees={worktrees}
|
||||
isWorktreeSelected={isWorktreeSelected}
|
||||
hasRunningFeatures={hasRunningFeatures}
|
||||
isActivating={isActivating}
|
||||
branchCardCounts={branchCardCounts}
|
||||
isDevServerRunning={isDevServerRunning}
|
||||
getDevServerInfo={getDevServerInfo}
|
||||
isAutoModeRunningForWorktree={isAutoModeRunningForWorktree}
|
||||
isTestRunningForWorktree={isTestRunningForWorktree}
|
||||
getTestSessionInfo={getTestSessionInfo}
|
||||
onSelectWorktree={handleSelectWorktree}
|
||||
// Branch switching props
|
||||
branches={branches}
|
||||
filteredBranches={filteredBranches}
|
||||
branchFilter={branchFilter}
|
||||
isLoadingBranches={isLoadingBranches}
|
||||
isSwitching={isSwitching}
|
||||
onBranchDropdownOpenChange={handleBranchDropdownOpenChange}
|
||||
onBranchFilterChange={setBranchFilter}
|
||||
onSwitchBranch={handleSwitchBranch}
|
||||
onCreateBranch={onCreateBranch}
|
||||
// Action dropdown props
|
||||
isPulling={isPulling}
|
||||
isPushing={isPushing}
|
||||
isStartingDevServer={isStartingDevServer}
|
||||
aheadCount={aheadCount}
|
||||
behindCount={behindCount}
|
||||
hasRemoteBranch={hasRemoteBranch}
|
||||
gitRepoStatus={gitRepoStatus}
|
||||
hasTestCommand={hasTestCommand}
|
||||
isStartingTests={isStartingTests}
|
||||
hasInitScript={hasInitScript}
|
||||
onActionsDropdownOpenChange={handleActionsDropdownOpenChange}
|
||||
onPull={handlePull}
|
||||
onPush={handlePush}
|
||||
onPushNewBranch={handlePushNewBranch}
|
||||
onOpenInEditor={handleOpenInEditor}
|
||||
onOpenInIntegratedTerminal={handleOpenInIntegratedTerminal}
|
||||
onOpenInExternalTerminal={handleOpenInExternalTerminal}
|
||||
onViewChanges={handleViewChanges}
|
||||
onDiscardChanges={handleDiscardChanges}
|
||||
onCommit={onCommit}
|
||||
onCreatePR={onCreatePR}
|
||||
onAddressPRComments={onAddressPRComments}
|
||||
onResolveConflicts={onResolveConflicts}
|
||||
onMerge={handleMerge}
|
||||
onDeleteWorktree={onDeleteWorktree}
|
||||
onStartDevServer={handleStartDevServer}
|
||||
onStopDevServer={handleStopDevServer}
|
||||
onOpenDevServerUrl={handleOpenDevServerUrl}
|
||||
onViewDevServerLogs={handleViewDevServerLogs}
|
||||
onRunInitScript={handleRunInitScript}
|
||||
onToggleAutoMode={handleToggleAutoMode}
|
||||
onStartTests={handleStartTests}
|
||||
onStopTests={handleStopTests}
|
||||
onViewTestLogs={handleViewTestLogs}
|
||||
/>
|
||||
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCreateWorktree}
|
||||
title="Create new worktree"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={async () => {
|
||||
const removedWorktrees = await fetchWorktrees();
|
||||
if (removedWorktrees && removedWorktrees.length > 0 && onRemovedWorktrees) {
|
||||
onRemovedWorktrees(removedWorktrees);
|
||||
}
|
||||
}}
|
||||
disabled={isLoading}
|
||||
title="Refresh worktrees"
|
||||
>
|
||||
{isLoading ? <Spinner size="xs" /> : <RefreshCw className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Standard tabs layout for 1-2 worktrees */
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{mainWorktree && (
|
||||
<WorktreeTab
|
||||
@@ -782,7 +886,7 @@ export function WorktreePanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Worktrees section - only show if enabled */}
|
||||
{/* Worktrees section - only show if enabled and not using dropdown layout */}
|
||||
{useWorktreesEnabled && (
|
||||
<>
|
||||
<div className="w-px h-5 bg-border mx-2" />
|
||||
@@ -883,6 +987,8 @@ export function WorktreePanel({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* View Changes Dialog */}
|
||||
<ViewWorktreeChangesDialog
|
||||
|
||||
Reference in New Issue
Block a user