import { View, Text, Pressable, Platform, ActivityIndicator, StatusBar, ScrollView, type GestureResponderEvent, type PressableStateCallbackType, type ViewStyle, } from "react-native"; import * as Haptics from "expo-haptics"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@getpaseo/protocol/branch-slug"; import { ProjectIconView } from "@/components/project-icon-view"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { memo, useCallback, useMemo, useState, useEffect, useRef, type ReactElement, type MutableRefObject, type Ref, } from "react"; import { router, usePathname, type Href } from "expo-router"; import { navigateToWorkspace, useActiveWorkspaceSelection, type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; import { DiffStat } from "@/components/diff-stat"; import { Archive, CircleAlert, CircleCheck, ChevronDown, ChevronRight, Copy, ExternalLink, FolderPlus, GitPullRequest, Settings, MoreVertical, Pencil, Plus, Trash2, } from "lucide-react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { DraggableList, type DraggableRenderItemInfo } from "./draggable-list"; import type { DraggableListDragHandleProps } from "./draggable-list.types"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useProjectIconDataByProjectKey } from "@/projects/project-icons"; import { buildHostNewWorkspaceRoute, buildProjectSettingsRoute, parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; import { createSidebarWorkspaceEntry, type SidebarProjectEntry, type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useShowShortcutBadges } from "@/hooks/use-show-shortcut-badges"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, useContextMenu, } from "@/components/ui/context-menu"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { SyncedLoader } from "@/components/synced-loader"; import { useToast } from "@/contexts/toast-context"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; import { decideLongPressMove } from "@/utils/sidebar-gesture-arbitration"; import { confirmDialog } from "@/utils/confirm-dialog"; import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list"; import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, SidebarWorkspaceShortcutBadge, SidebarWorkspaceTrailingActionBase, SidebarWorkspaceTrailingActionOverlay, SidebarWorkspaceTrailingActionSlot, } from "@/components/sidebar/sidebar-workspace-row-content"; import { useHydratedWorkspaceEntries, useProjectNamesMap, } from "@/hooks/use-status-mode-workspaces"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { openExternalUrl } from "@/utils/open-external-url"; import { requireWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory, } from "@/utils/workspace-execution"; import { confirmRiskyWorktreeArchive } from "@/git/worktree-archive-warning"; import { archiveWorkspaceOptimistically, archiveWorkspacesOptimistically, } from "@/workspace/workspace-archive"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey; const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; const WORKSPACE_STATUS_DOT_WIDTH = 14; const DEFAULT_STATUS_DOT_SIZE = 7; const EMPHASIZED_STATUS_DOT_SIZE = 9; const DEFAULT_STATUS_DOT_OFFSET = 0; const EMPHASIZED_STATUS_DOT_OFFSET = -1; const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedFolderPlus = withUnistyles(FolderPlus); const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedTrash2 = withUnistyles(Trash2); const ThemedSettings = withUnistyles(Settings); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); const ThemedPencil = withUnistyles(Pencil); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] }); const syncedLoaderColorMapping = (theme: Theme) => ({ color: theme.colorScheme === "light" ? theme.colors.palette.amber[700] : theme.colors.palette.amber[500], }); function getPrIconUniMapping(state: PrHint["state"]) { switch (state) { case "merged": return purpleColorMapping; case "open": return greenColorMapping; case "closed": return redColorMapping; } } function isWorkspaceSelected(input: { selection: ActiveWorkspaceSelection | null; serverId: string | null; workspaceId: string; enabled: boolean; }): boolean { return ( input.enabled && input.selection?.serverId === input.serverId && input.selection.workspaceId === input.workspaceId ); } function isProjectSelectedByRoute(input: { selection: ActiveWorkspaceSelection | null; project: SidebarProjectEntry; serverId: string | null; enabled: boolean; }): boolean { return ( input.enabled && input.selection?.serverId === input.serverId && input.project.workspaces.some( (workspace) => workspace.workspaceId === input.selection?.workspaceId, ) ); } function activeWorkspaceSelectionKey(selection: ActiveWorkspaceSelection | null): string { return selection ? `${selection.serverId}:${selection.workspaceId}` : ""; } function selectionForSelectedWorkspace( selected: boolean, workspace: SidebarWorkspaceEntry, ): ActiveWorkspaceSelection | null { return selected ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } : null; } interface SidebarWorkspaceListProps { projects: SidebarProjectEntry[]; serverId: string | null; collapsedProjectKeys: ReadonlySet; onToggleProjectCollapsed: (projectKey: string) => void; shortcutIndexByWorkspaceKey: Map; groupMode: "project" | "status"; isRefreshing?: boolean; onRefresh?: () => void; onWorkspacePress?: () => void; onAddProject?: () => void; listFooterComponent?: ReactElement | null; /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ parentGestureRef?: MutableRefObject; } interface ProjectHeaderRowProps { project: SidebarProjectEntry; displayName: string; iconDataUri: string | null; workspace: SidebarWorkspaceEntry | null; selected?: boolean; chevron: "expand" | "collapse" | null; onPress: () => void; serverId: string | null; canCreateWorktree: boolean; isProjectActive?: boolean; onWorkspacePress?: () => void; onWorktreeCreated?: (workspaceId: string) => void; shortcutNumber?: number | null; showShortcutBadge?: boolean; drag: () => void; isDragging: boolean; isArchiving?: boolean; menuController: ReturnType | null; onRemoveProject?: () => void; removeProjectStatus?: "idle" | "pending"; dragHandleProps?: DraggableListDragHandleProps; } interface WorkspaceRowInnerProps { workspace: SidebarWorkspaceEntry; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; drag: () => void; isDragging: boolean; isArchiving: boolean; isCreating?: boolean; dragHandleProps?: DraggableListDragHandleProps; menuController: ReturnType | null; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; } function getWorkspaceArchiveStatus( isWorktree: boolean, archiveStatus: "idle" | "pending" | "success", isArchivingWorkspace: boolean, ): "idle" | "pending" | "success" { if (isWorktree) return archiveStatus; if (isArchivingWorkspace) return "pending"; return "idle"; } export function useSidebarWorkspaceEntry( serverId: string | null, workspaceId: string | null, ): SidebarWorkspaceEntry | null { const projectWorkspaceEntry = useCallback( (workspace: WorkspaceDescriptor): SidebarWorkspaceEntry => createSidebarWorkspaceEntry({ serverId: serverId ?? "", workspace }), [serverId], ); return useWorkspaceFields(serverId, workspaceId, projectWorkspaceEntry); } export function PrBadge({ hint }: { hint: PrHint }) { const [isHovered, setIsHovered] = useState(false); const handlePressIn = useCallback((event: GestureResponderEvent) => { event.stopPropagation(); }, []); const handlePress = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); void openExternalUrl(hint.url); }, [hint.url], ); const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); const textStyle = isHovered ? prBadgeTextHoveredCombined : prBadgeStyles.text; const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state); return ( {isHovered ? ( ) : ( )} #{hint.number} ); } function prBadgePressableStyle({ pressed }: PressableStateCallbackType) { return [prBadgeStyles.badge, pressed && prBadgeStyles.badgePressed]; } function projectKebabStyle({ hovered = false, }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered]; } function workspaceKebabStyle({ hovered = false, }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.kebabButton, hovered && styles.kebabButtonHovered]; } function getProjectWorkspaceRowStyle({ isDragging, selected, isHovered, }: { isDragging: boolean; selected: boolean; isHovered: boolean; }) { return [ styles.workspaceRow, isDragging && styles.workspaceRowDragging, selected && styles.sidebarRowSelected, isHovered && styles.workspaceRowHovered, ]; } function noop() {} const prBadgeStyles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", alignItems: "center", gap: 2, }, badgePressed: { opacity: 0.82, }, text: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, lineHeight: 14, color: theme.colors.foregroundMuted, }, textHovered: { color: theme.colors.foreground, }, })); const prBadgeTextHoveredCombined = [prBadgeStyles.text, prBadgeStyles.textHovered]; function StatusDotOverlay({ dotColorStyle, size, offset, }: { dotColorStyle: ViewStyle; size: number; offset: number; }) { const overlayStyle = useMemo( () => [ styles.statusDotOverlay, dotColorStyle, { width: size, height: size, right: offset, bottom: offset, }, ], [dotColorStyle, size, offset], ); return ; } function ProjectLeadingVisual({ displayName, iconDataUri, workspace, projectKey, chevron = null, showChevron = false, isArchiving = false, }: { displayName: string; iconDataUri: string | null; workspace: SidebarWorkspaceEntry | null; projectKey: string; chevron?: "expand" | "collapse" | null; showChevron?: boolean; isArchiving?: boolean; }) { const placeholderLabel = projectIconPlaceholderLabelFromDisplayName(displayName); const placeholderInitial = placeholderLabel.charAt(0).toUpperCase(); const activeWorkspace = workspace; const shouldShowWorkspaceStatus = activeWorkspace !== null && (isArchiving || activeWorkspace.statusBucket !== "done"); const shouldShowSyncedLoader = activeWorkspace ? shouldRenderSyncedStatusLoader({ bucket: activeWorkspace.statusBucket }) : false; if (showChevron && chevron !== null) { return ( ); } if (!shouldShowWorkspaceStatus || !activeWorkspace) { return ( ); } return ( ); } function ProjectRowTrailingActions({ project, displayName, canCreateWorktree, isHovered, isMobileBreakpoint, isProjectActive, onBeginWorkspaceSetup, onRemoveProject, removeProjectStatus, }: { project: SidebarProjectEntry; displayName: string; canCreateWorktree: boolean; isHovered: boolean; isMobileBreakpoint: boolean; isProjectActive: boolean; onBeginWorkspaceSetup: () => void; onRemoveProject?: () => void; removeProjectStatus: "idle" | "pending" | "success"; }) { const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint; return ( {canCreateWorktree ? ( ) : null} {onRemoveProject ? ( ) : null} ); } const trash2LeadingIcon = ; const settingsLeadingIcon = ; const copyLeadingIcon = ; const markAsReadLeadingIcon = ( ); const archiveLeadingIcon = ; const renameLeadingIcon = ; function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( ); } function ProjectKebabMenu({ projectKey, onRemoveProject, removeProjectStatus, }: { projectKey: string; onRemoveProject: () => void; removeProjectStatus: "idle" | "pending" | "success"; }) { const handleOpenProjectSettings = useCallback(() => { if (projectKey.trim().length === 0) return; router.navigate(buildProjectSettingsRoute(projectKey)); }, [projectKey]); const canOpenProjectSettings = projectKey.trim().length > 0; return ( {renderKebabTriggerIcon} {canOpenProjectSettings ? ( Open project settings ) : null} Remove project ); } function WorkspaceRowRightGroup({ workspace, isHovered, isTouchPlatform, isCreating, showShortcutBadge, shortcutNumber, archiveLabel, archiveStatus, archivePendingLabel, archiveShortcutKeys, onArchive, onMarkAsRead, onCopyBranchName, onCopyPath, onRename, }: { workspace: SidebarWorkspaceEntry; isHovered: boolean; isTouchPlatform: boolean; isCreating: boolean; showShortcutBadge: boolean; shortcutNumber: number | null; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onMarkAsRead?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; }) { const showShortcut = showShortcutBadge && shortcutNumber !== null; const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); const showKebabInSlot = showKebab && !showShortcut; const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); return ( <> {isCreating ? Creating... : null} {shouldRenderActionSlot ? ( {workspace.diffStat ? ( ) : null} {onArchive ? ( ) : null} ) : null} ); } function WorkspaceKebabMenu({ workspaceKey, onCopyPath, onCopyBranchName, onRename, onMarkAsRead, onArchive, archiveLabel, archiveStatus, archivePendingLabel, archiveShortcutKeys, }: { workspaceKey: string; onCopyPath?: () => void; onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; }) { const archiveTrailing = useMemo( () => (archiveShortcutKeys ? : null), [archiveShortcutKeys], ); return ( {renderKebabTriggerIcon} {onCopyPath ? ( Copy path ) : null} {onCopyBranchName ? ( Copy branch name ) : null} {onRename ? ( Rename workspace ) : null} {onMarkAsRead ? ( Mark as read ) : null} {archiveLabel ?? "Archive"} ); } function ProjectIcon({ iconDataUri, placeholderInitial, projectKey, }: { iconDataUri: string | null; placeholderInitial: string; projectKey: string; }) { return ( ); } function ProjectLeadingVisualStatus({ iconDataUri, placeholderInitial, projectKey, isArchiving, shouldShowSyncedLoader, activeWorkspace, }: { iconDataUri: string | null; placeholderInitial: string; projectKey: string; isArchiving: boolean; shouldShowSyncedLoader: boolean; activeWorkspace: SidebarWorkspaceEntry; }) { if (isArchiving) { return ( ); } if (shouldShowSyncedLoader) { return ( ); } if (activeWorkspace.statusBucket === "needs_input") { return ( ); } const dotColorStyle = getStatusDotColorStyle(activeWorkspace.statusBucket); const statusDotSize = isEmphasizedStatusDotBucket(activeWorkspace.statusBucket) ? EMPHASIZED_STATUS_DOT_SIZE : DEFAULT_STATUS_DOT_SIZE; const statusDotOffset = statusDotSize === EMPHASIZED_STATUS_DOT_SIZE ? EMPHASIZED_STATUS_DOT_OFFSET : DEFAULT_STATUS_DOT_OFFSET; return ( {dotColorStyle ? ( ) : null} ); } function ProjectInlineChevron({ chevron }: { chevron: "expand" | "collapse" | null }) { if (chevron === null) { return null; } if (chevron === "collapse") { return ; } return ; } function NewWorktreeButton({ displayName, onPress, visible, loading = false, testID, showShortcutHint = false, }: { displayName: string; onPress: () => void; visible: boolean; loading?: boolean; testID: string; showShortcutHint?: boolean; }) { const newWorktreeKeys = useShortcutKeys("new-worktree"); const pressableStyle = useCallback( ({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.projectIconActionButton, !visible && styles.projectIconActionButtonHidden, (Boolean(hovered) || pressed) && !loading && styles.projectIconActionButtonHovered, ], [visible, loading], ); const handlePress = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); onPress(); }, [onPress], ); return ( {({ hovered, pressed }) => loading ? ( ) : ( ) } New workspace {showShortcutHint && newWorktreeKeys ? ( ) : null} ); } function useLongPressDragInteraction(input: { drag: () => void; menuController: ReturnType | null; }) { const didLongPressRef = useRef(false); const dragArmedRef = useRef(false); const dragActivatedRef = useRef(false); const didStartDragRef = useRef(false); const scrollIntentRef = useRef(false); const menuOpenedRef = useRef(false); const touchStartRef = useRef<{ x: number; y: number } | null>(null); const touchCurrentRef = useRef<{ x: number; y: number } | null>(null); const dragArmTimerRef = useRef | null>(null); const contextMenuTimerRef = useRef | null>(null); const clearTimers = useCallback(() => { if (dragArmTimerRef.current) { clearTimeout(dragArmTimerRef.current); dragArmTimerRef.current = null; } if (contextMenuTimerRef.current) { clearTimeout(contextMenuTimerRef.current); contextMenuTimerRef.current = null; } }, []); const openContextMenuAtStartPoint = useCallback(() => { if (!input.menuController || !touchStartRef.current) { return; } const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; input.menuController.setAnchorRect({ x: touchStartRef.current.x, y: touchStartRef.current.y + statusBarHeight, width: 0, height: 0, }); input.menuController.setOpen(true); menuOpenedRef.current = true; didLongPressRef.current = true; }, [input.menuController]); const handleLongPress = useCallback(() => { // Manual timers own long-press behavior on mobile. }, []); useEffect(() => { return () => { clearTimers(); }; }, [clearTimers]); const armTimers = useCallback(() => { clearTimers(); const DRAG_ARM_DELAY_MS = 180; const DRAG_ARM_STATIONARY_SLOP_PX = 4; const CONTEXT_MENU_DELAY_MS = 450; const CONTEXT_MENU_STATIONARY_SLOP_PX = 6; dragArmTimerRef.current = setTimeout(() => { if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) { return; } const start = touchStartRef.current; const current = touchCurrentRef.current ?? start; if (!start || !current) { return; } const dx = current.x - start.x; const dy = current.y - start.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > DRAG_ARM_STATIONARY_SLOP_PX) { return; } dragArmedRef.current = true; dragActivatedRef.current = true; didLongPressRef.current = true; void Haptics.selectionAsync().catch(() => {}); input.drag(); }, DRAG_ARM_DELAY_MS); if (!input.menuController || platformIsWeb) { return; } contextMenuTimerRef.current = setTimeout(() => { if (scrollIntentRef.current || didStartDragRef.current || menuOpenedRef.current) { return; } const start = touchStartRef.current; const current = touchCurrentRef.current ?? start; if (!start || !current) { return; } const dx = current.x - start.x; const dy = current.y - start.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance > CONTEXT_MENU_STATIONARY_SLOP_PX) { return; } void Haptics.selectionAsync().catch(() => {}); openContextMenuAtStartPoint(); }, CONTEXT_MENU_DELAY_MS); }, [clearTimers, input, openContextMenuAtStartPoint]); const handleDragIntent = useCallback( (_details: { dx: number; dy: number; distance: number }) => { if (!dragActivatedRef.current) { return; } didStartDragRef.current = true; didLongPressRef.current = true; clearTimers(); void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}); }, [clearTimers], ); const handleScrollIntent = useCallback( (_details: { dx: number; dy: number; distance: number }) => { scrollIntentRef.current = true; didLongPressRef.current = true; clearTimers(); }, [clearTimers], ); const handleSwipeIntent = useCallback( (_details: { dx: number; dy: number; distance: number }) => { didLongPressRef.current = true; clearTimers(); }, [clearTimers], ); const handlePressIn = useCallback( (event: GestureResponderEvent) => { didLongPressRef.current = false; dragArmedRef.current = false; dragActivatedRef.current = false; didStartDragRef.current = false; scrollIntentRef.current = false; menuOpenedRef.current = false; touchStartRef.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY, }; touchCurrentRef.current = { x: event.nativeEvent.pageX, y: event.nativeEvent.pageY, }; armTimers(); }, [armTimers], ); const handleTouchMove = useCallback( (event: GestureResponderEvent) => { const start = touchStartRef.current; if (!start || didStartDragRef.current || menuOpenedRef.current) { return; } const touch = event?.nativeEvent?.touches?.[0] ?? event?.nativeEvent; const x = touch?.pageX; const y = touch?.pageY; if (typeof x !== "number" || typeof y !== "number") { return; } const current = { x, y }; touchCurrentRef.current = current; const dx = current.x - start.x; const dy = current.y - start.y; const distance = Math.sqrt(dx * dx + dy * dy); const decision = decideLongPressMove({ dragArmed: dragArmedRef.current, didStartDrag: didStartDragRef.current, startPoint: start, currentPoint: current, }); if (decision === "vertical_scroll") { handleScrollIntent({ dx, dy, distance }); return; } if (decision === "horizontal_swipe" || decision === "cancel_long_press") { handleSwipeIntent({ dx, dy, distance }); return; } if (decision === "start_drag") { handleDragIntent({ dx, dy, distance }); } }, [handleDragIntent, handleScrollIntent, handleSwipeIntent], ); const handlePressOut = useCallback(() => { clearTimers(); dragArmedRef.current = false; dragActivatedRef.current = false; touchStartRef.current = null; touchCurrentRef.current = null; }, [clearTimers]); return { didLongPressRef, handleLongPress, handlePressIn, handleTouchMove, handlePressOut, }; } function ProjectHeaderRow({ project, displayName, iconDataUri, workspace, selected = false, chevron, onPress, serverId, canCreateWorktree, isProjectActive = false, onWorkspacePress, onWorktreeCreated: _onWorktreeCreated, shortcutNumber = null, showShortcutBadge = false, drag, isDragging, isArchiving = false, menuController, onRemoveProject, removeProjectStatus = "idle", dragHandleProps, }: ProjectHeaderRowProps) { const [isHovered, setIsHovered] = useState(false); const isMobileBreakpoint = useIsCompactFormFactor(); const handleBeginWorkspaceSetup = useCallback(() => { if (!serverId) { return; } router.navigate( buildHostNewWorkspaceRoute(serverId, project.iconWorkingDir, { displayName, projectId: project.projectKey, }) as Href, ); onWorkspacePress?.(); }, [displayName, onWorkspacePress, project.iconWorkingDir, project.projectKey, serverId]); const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const _toast = useToast(); const interaction = useLongPressDragInteraction({ drag, menuController, }); const { role: _dragRole, tabIndex: _dragTabIndex, "aria-roledescription": _dragRoleDescription, ...dragAttributes } = dragHandleProps?.attributes ?? {}; const handlePress = useCallback(() => { if (interaction.didLongPressRef.current) { interaction.didLongPressRef.current = false; return; } onPress(); }, [interaction.didLongPressRef, onPress]); const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); const projectRowStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ styles.projectRow, isDragging && styles.projectRowDragging, selected && styles.sidebarRowSelected, isHovered && styles.projectRowHovered, pressed && styles.projectRowPressed, ], [isDragging, selected, isHovered], ); const rowChildren = ( <> {displayName} {showShortcutBadge && shortcutNumber !== null ? ( ) : null} ); if (menuController) { return ( } onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} > {rowChildren} ); } return ( } onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} > {rowChildren} ); } function WorkspaceRowInner({ workspace, selected, shortcutNumber, showShortcutBadge, onPress, drag, isDragging, isArchiving, isCreating = false, dragHandleProps, menuController, archiveLabel, archiveStatus = "idle", archivePendingLabel, onArchive, onCopyBranchName, onCopyPath, onRename, archiveShortcutKeys, }: WorkspaceRowInnerProps) { const _isCompact = useIsCompactFormFactor(); const isTouchPlatform = platformIsNative; const interaction = useLongPressDragInteraction({ drag, menuController, }); const { role: _dragRole, tabIndex: _dragTabIndex, "aria-roledescription": _dragRoleDescription, ...dragAttributes } = dragHandleProps?.attributes ?? {}; const handlePress = useCallback(() => { if (interaction.didLongPressRef.current) { interaction.didLongPressRef.current = false; return; } onPress(); }, [interaction.didLongPressRef, onPress]); const accessibilityState = useMemo(() => ({ selected }), [selected]); return ( {({ isHovered, hoverHandlers }) => { const isDesktop = !isTouchPlatform; const showScriptsIcon = isDesktop && workspace.hasRunningScripts; const hasRunningService = workspace.scripts.some( (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", ); let scriptIconKind: "service" | "command" | null = null; if (showScriptsIcon) { scriptIconKind = hasRunningService ? "service" : "command"; } const workspaceRowStyle = getProjectWorkspaceRowStyle({ isDragging, selected, isHovered, }); return ( } style={styles.workspaceRowContainer} {...hoverHandlers} > ); }} ); } function WorkspaceRowWithMenu({ workspace, selected, shortcutNumber, showShortcutBadge, onPress, drag, isDragging, dragHandleProps, canCopyBranchName, isCreating = false, }: { workspace: SidebarWorkspaceEntry; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; isCreating?: boolean; }) { const toast = useToast(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const queryClient = useQueryClient(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); const workspaceDirectory = resolveWorkspaceExecutionDirectory({ workspaceDirectory: workspace.workspaceDirectory, }); const archiveStatus = useCheckoutGitActionsStore((state) => workspaceDirectory ? state.getStatus({ serverId: workspace.serverId, cwd: workspaceDirectory, actionId: "archive-worktree", }) : "idle", ); const isWorktree = workspace.workspaceKind === "worktree"; const isArchiving = isWorktree ? workspace.archivingAt !== null : isArchivingWorkspace; const redirectAfterArchive = useCallback(() => { redirectIfArchivingActiveWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace), }); }, [selected, workspace]); const archiveWorktreeAfterConfirmation = useCallback(async () => { if (isArchiving) { return; } const confirmed = await confirmRiskyWorktreeArchive({ worktreeName: workspace.name, isDirty: workspace.archiveHasUncommittedChanges, aheadOfOrigin: workspace.archiveUnpushedCommitCount, diffStat: workspace.diffStat, }); if (!confirmed) { return; } let archiveDirectory: string; try { archiveDirectory = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); } catch (error) { toast.error(error instanceof Error ? error.message : "Workspace path not available"); return; } if (!archiveDirectory) { toast.error("Workspace path not available"); return; } redirectAfterArchive(); void archiveWorktree({ serverId: workspace.serverId, cwd: archiveDirectory, worktreePath: archiveDirectory, }).catch((error) => { const message = error instanceof Error ? error.message : "Failed to archive worktree"; toast.error(message); }); }, [archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace]); const handleArchiveWorktree = useCallback(() => { void archiveWorktreeAfterConfirmation(); }, [archiveWorktreeAfterConfirmation]); const hideWorkspaceAfterConfirmation = useCallback(async () => { if (isArchivingWorkspace) { return; } const confirmed = await confirmDialog({ title: "Hide workspace?", message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, confirmLabel: "Hide", cancelLabel: "Cancel", destructive: true, }); if (!confirmed) { return; } const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { toast.error("Host is not connected"); return; } setIsArchivingWorkspace(true); try { await archiveWorkspaceOptimistically({ client, workspace, afterHide: redirectAfterArchive, }); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); } finally { setIsArchivingWorkspace(false); } }, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); const handleArchiveWorkspace = useCallback(() => { void hideWorkspaceAfterConfirmation(); }, [hideWorkspaceAfterConfirmation]); const handleCopyPath = useCallback(() => { let copyTargetDirectory: string; try { copyTargetDirectory = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); } catch (error) { toast.error(error instanceof Error ? error.message : "Workspace path not available"); return; } void Clipboard.setStringAsync(copyTargetDirectory); toast.copied("Path copied"); }, [toast, workspace.workspaceDirectory, workspace.workspaceId]); const handleCopyBranchName = useCallback(() => { void Clipboard.setStringAsync(workspace.name); toast.copied("Branch name copied"); }, [toast, workspace.name]); const renameMutation = useMutation({ mutationFn: async (branch: string) => { const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { throw new Error("Host is not connected"); } const targetCwd = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); const payload = await client.renameBranch({ cwd: targetCwd, branch }); if (!payload.success || payload.error) { throw new Error(payload.error?.message ?? "Failed to rename branch"); } return { targetCwd }; }, onSuccess: async ({ targetCwd }) => { await invalidateCheckoutGitQueriesForClient(queryClient, { serverId: workspace.serverId, cwd: targetCwd, }); }, }); const handleOpenRename = useCallback(() => { setIsRenameOpen(true); }, []); const handleCloseRename = useCallback(() => { setIsRenameOpen(false); }, []); const handleSubmitRename = useCallback( async (value: string) => { await renameMutation.mutateAsync(slugify(value)); }, [renameMutation], ); const validateRenameSlug = useCallback((value: string): string | null => { const result = validateBranchSlug(slugify(value)); if (result.valid) return null; return result.error ?? "Invalid branch name"; }, []); const archiveShortcutKeys = useShortcutKeys("archive-worktree"); const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); useKeyboardActionHandler({ handlerId: `worktree-archive-${workspace.workspaceKey}`, actions: ["worktree.archive"], enabled: selected && !isArchiving, priority: 0, handle: () => { if (isWorktree) { void archiveWorktreeAfterConfirmation(); } else { handleArchiveWorkspace(); } return true; }, }); return ( <> ); } function NonGitProjectRowWithMenuContent({ project, displayName, iconDataUri, workspace, selected, onPress, shortcutNumber, showShortcutBadge, drag, isDragging, dragHandleProps, }: { project: SidebarProjectEntry; displayName: string; iconDataUri: string | null; workspace: SidebarWorkspaceEntry; selected: boolean; onPress: () => void; shortcutNumber: number | null; showShortcutBadge: boolean; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; }) { const toast = useToast(); const contextMenu = useContextMenu(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const redirectAfterArchive = useCallback(() => { redirectIfArchivingActiveWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace), }); }, [selected, workspace]); const handleArchiveWorkspace = useCallback(() => { if (isArchivingWorkspace) { return; } void (async () => { const confirmed = await confirmDialog({ title: "Hide workspace?", message: `Hide "${workspace.name}" from the sidebar?\n\nFiles on disk will not be changed.`, confirmLabel: "Hide", cancelLabel: "Cancel", destructive: true, }); if (!confirmed) { return; } const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { toast.error("Host is not connected"); return; } setIsArchivingWorkspace(true); void (async () => { try { await archiveWorkspaceOptimistically({ client, workspace, afterHide: redirectAfterArchive, }); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); } finally { setIsArchivingWorkspace(false); } })(); })(); }, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); return ( <> Hide from sidebar ); } function NonGitProjectRowWithMenu(props: { project: SidebarProjectEntry; displayName: string; iconDataUri: string | null; workspace: SidebarWorkspaceEntry; selected: boolean; onPress: () => void; shortcutNumber: number | null; showShortcutBadge: boolean; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; }) { return ( ); } function FlattenedProjectRow({ project, displayName, iconDataUri, rowModel, onPress, serverId, onWorkspacePress, onWorktreeCreated, shortcutNumber, showShortcutBadge, drag, isDragging, dragHandleProps, isProjectActive = false, onRemoveProject, removeProjectStatus, selectionEnabled, activeWorkspaceSelection, }: { project: SidebarProjectEntry; displayName: string; iconDataUri: string | null; rowModel: Extract, { kind: "workspace_link" }>; onPress: () => void; serverId: string | null; onWorkspacePress?: () => void; onWorktreeCreated?: (workspaceId: string) => void; shortcutNumber: number | null; showShortcutBadge: boolean; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; isProjectActive?: boolean; onRemoveProject?: () => void; removeProjectStatus?: "idle" | "pending"; selectionEnabled: boolean; activeWorkspaceSelection: ActiveWorkspaceSelection | null; }) { const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId); const selected = isWorkspaceSelected({ selection: activeWorkspaceSelection, serverId, workspaceId: rowModel.workspace.workspaceId, enabled: selectionEnabled, }); if (!workspace) { return null; } if (project.projectKind === "directory") { return ( ); } return ( ); } interface WorkspaceRowItemProps { workspace: SidebarWorkspaceEntry; shortcutNumber: number | null; showShortcutBadge: boolean; canCopyBranchName: boolean; isCreating?: boolean; selectionEnabled: boolean; serverId: string | null; activeWorkspaceSelection: ActiveWorkspaceSelection | null; onWorkspacePress?: () => void; drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps; } function WorkspaceRowItem({ workspace, shortcutNumber, showShortcutBadge, canCopyBranchName, isCreating = false, selectionEnabled, serverId, activeWorkspaceSelection, onWorkspacePress, drag, isDragging = false, dragHandleProps, }: WorkspaceRowItemProps) { const handlePress = useCallback(() => { if (!serverId) { return; } onWorkspacePress?.(); navigateToWorkspace(serverId, workspace.workspaceId); }, [serverId, onWorkspacePress, workspace.workspaceId]); return ( ); } function areWorkspaceRowItemPropsEqual( previous: WorkspaceRowItemProps, next: WorkspaceRowItemProps, ): boolean { const previousSelected = isWorkspaceSelected({ selection: previous.activeWorkspaceSelection, serverId: previous.workspace.serverId, workspaceId: previous.workspace.workspaceId, enabled: previous.selectionEnabled, }); const nextSelected = isWorkspaceSelected({ selection: next.activeWorkspaceSelection, serverId: next.workspace.serverId, workspaceId: next.workspace.workspaceId, enabled: next.selectionEnabled, }); return ( previous.workspace === next.workspace && previous.shortcutNumber === next.shortcutNumber && previous.showShortcutBadge === next.showShortcutBadge && previous.canCopyBranchName === next.canCopyBranchName && previous.isCreating === next.isCreating && previous.serverId === next.serverId && previous.onWorkspacePress === next.onWorkspacePress && previous.drag === next.drag && previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && previousSelected === nextSelected ); } const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual); function WorkspaceRow({ workspace, shortcutNumber, showShortcutBadge, onPress, drag, isDragging, dragHandleProps, canCopyBranchName, isCreating = false, selected, }: { workspace: SidebarWorkspaceEntry; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; isCreating?: boolean; selected: boolean; }) { const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId); if (!hydratedWorkspace) { return null; } return ( ); } function ProjectBlock({ project, collapsed, displayName, iconDataUri, serverId, selectionEnabled, showShortcutBadges, shortcutIndexByWorkspaceKey, parentGestureRef, onToggleCollapsed, onWorkspacePress, onWorkspaceReorder, onWorktreeCreated, drag, isDragging, dragHandleProps, useNestable, creatingWorkspaceIds, activeWorkspaceSelection, }: { project: SidebarProjectEntry; collapsed: boolean; displayName: string; iconDataUri: string | null; serverId: string | null; selectionEnabled: boolean; showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; parentGestureRef?: MutableRefObject; onToggleCollapsed: (projectKey: string) => void; onWorkspacePress?: () => void; onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; useNestable: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; }) { const rowModel = useMemo( () => buildSidebarProjectRowModel({ project, collapsed, }), [collapsed, project], ); const active = isProjectSelectedByRoute({ selection: activeWorkspaceSelection, serverId, project, enabled: selectionEnabled, }); const renderWorkspaceRow = useCallback( ( item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps; }, ) => { return ( ); }, [ project.projectKind, activeWorkspaceSelection, creatingWorkspaceIds, onWorkspacePress, serverId, selectionEnabled, shortcutIndexByWorkspaceKey, showShortcutBadges, ], ); const renderWorkspace = useCallback( ({ item, drag: workspaceDrag, isActive, dragHandleProps: workspaceDragHandleProps, }: DraggableRenderItemInfo) => { return renderWorkspaceRow(item, { drag: workspaceDrag, isDragging: isActive, dragHandleProps: workspaceDragHandleProps, }); }, [renderWorkspaceRow], ); const handleWorkspaceDragEnd = useCallback( (workspaces: SidebarWorkspaceEntry[]) => { onWorkspaceReorder(project.projectKey, workspaces); }, [onWorkspaceReorder, project.projectKey], ); const toast = useToast(); const [isRemovingProject, setIsRemovingProject] = useState(false); const handleRemoveProject = useCallback(() => { if (isRemovingProject || !serverId) { return; } void (async () => { const confirmed = await confirmDialog({ title: "Remove project?", message: `Remove "${displayName}" from the sidebar?\n\nFiles on disk will not be changed.`, confirmLabel: "Remove", cancelLabel: "Cancel", destructive: true, }); if (!confirmed) { return; } const client = getHostRuntimeStore().getClient(serverId); if (!client) { toast.error("Host is not connected"); return; } setIsRemovingProject(true); void archiveWorkspacesOptimistically({ client, workspaces: project.workspaces, }).then((failures) => { if (failures.length > 0) { toast.error("Failed to remove some workspaces"); } setIsRemovingProject(false); return; }); })(); }, [isRemovingProject, serverId, displayName, toast, project.workspaces]); const flattenedRowWorkspaceId = rowModel.kind === "workspace_link" ? rowModel.workspace.workspaceId : null; const handleFlattenedRowPress = useCallback(() => { if (!serverId || !flattenedRowWorkspaceId) { return; } onWorkspacePress?.(); navigateToWorkspace(serverId, flattenedRowWorkspaceId); }, [serverId, flattenedRowWorkspaceId, onWorkspacePress]); const handleToggleCollapsed = useCallback(() => { onToggleCollapsed(project.projectKey); }, [onToggleCollapsed, project.projectKey]); return ( {rowModel.kind === "workspace_link" ? ( ) : ( <> {!collapsed ? ( ) : null} )} ); } type ProjectBlockProps = Parameters[0]; function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { return ( previous.project === next.project && previous.collapsed === next.collapsed && previous.displayName === next.displayName && previous.iconDataUri === next.iconDataUri && previous.serverId === next.serverId && previous.selectionEnabled === next.selectionEnabled && previous.showShortcutBadges === next.showShortcutBadges && previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey && previous.parentGestureRef === next.parentGestureRef && previous.onToggleCollapsed === next.onToggleCollapsed && previous.onWorkspacePress === next.onWorkspacePress && previous.onWorkspaceReorder === next.onWorkspaceReorder && previous.onWorktreeCreated === next.onWorktreeCreated && previous.drag === next.drag && previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && previous.useNestable === next.useNestable && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && areProjectBlockSelectionsEqual(previous, next) ); } function areProjectBlockSelectionsEqual( previous: ProjectBlockProps, next: ProjectBlockProps, ): boolean { const previousActive = isProjectSelectedByRoute({ selection: previous.activeWorkspaceSelection, project: previous.project, serverId: previous.serverId, enabled: previous.selectionEnabled, }); const nextActive = isProjectSelectedByRoute({ selection: next.activeWorkspaceSelection, project: next.project, serverId: next.serverId, enabled: next.selectionEnabled, }); if (previousActive !== nextActive) { return false; } if (!previousActive) { return true; } return ( activeWorkspaceSelectionKey(previous.activeWorkspaceSelection) === activeWorkspaceSelectionKey(next.activeWorkspaceSelection) ); } const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual); export function SidebarWorkspaceList({ projects, serverId, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, groupMode, isRefreshing: _isRefreshing = false, onRefresh: _onRefresh, onWorkspacePress, onAddProject, listFooterComponent, parentGestureRef, }: SidebarWorkspaceListProps) { const pathname = usePathname(); if (groupMode === "status") { return ( ); } return ( ); } function SidebarStatusModeWrapper({ serverId, shortcutIndexByWorkspaceKey: _projectShortcutIndex, onWorkspacePress, }: { serverId: string | null; shortcutIndexByWorkspaceKey: Map; onWorkspacePress?: () => void; }) { const hydratedWorkspaces = useHydratedWorkspaceEntries(serverId); const projectNamesByKey = useProjectNamesMap(serverId); const showShortcutBadges = useShowShortcutBadges(); return ( ); } function ProjectModeList({ projects, serverId, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, onWorkspacePress, onAddProject, listFooterComponent, parentGestureRef, pathname, }: Omit & { pathname: string; }) { const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( new Map(), ); const showShortcutBadges = useShowShortcutBadges(); const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); const isWorkspaceRoute = useMemo( () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), [pathname], ); const selectionEnabled = isWorkspaceRoute; const activeWorkspaceSelection = useActiveWorkspaceSelection(); const nativeScrollGestureProps = useMemo( () => parentGestureRef ? ({ // NestableScrollContainer forwards props to RNGH ScrollView. Keep // vertical scroll and sidebar close pan simultaneous: vertical // intent scrolls immediately, clear horizontal intent can still // activate close from inside the list. simultaneousHandlers: parentGestureRef, } as object) : undefined, [parentGestureRef], ); const projectIconByProjectKey = useProjectIconDataByProjectKey({ serverId, projects }); useEffect(() => { const timeouts = creatingWorkspaceTimeoutsRef.current; return () => { for (const timeout of timeouts.values()) { clearTimeout(timeout); } timeouts.clear(); }; }, []); useEffect(() => { if (creatingWorkspaceIds.size === 0) { return; } const visibleWorkspaceIds = new Set(); for (const project of projects) { for (const workspace of project.workspaces) { visibleWorkspaceIds.add(workspace.workspaceId); } } const removedWorkspaceIds = Array.from(creatingWorkspaceIds).filter( (workspaceId) => !visibleWorkspaceIds.has(workspaceId), ); if (removedWorkspaceIds.length === 0) { return; } for (const workspaceId of removedWorkspaceIds) { const timeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId); if (timeout) { clearTimeout(timeout); creatingWorkspaceTimeoutsRef.current.delete(workspaceId); } } setCreatingWorkspaceIds((current) => { const next = new Set(current); for (const workspaceId of removedWorkspaceIds) { next.delete(workspaceId); } return next; }); }, [creatingWorkspaceIds, projects]); const handleProjectDragEnd = useCallback( (reorderedProjects: SidebarProjectEntry[]) => { if (!serverId) { return; } const reorderedProjectKeys = reorderedProjects.map((project) => project.projectKey); const currentProjectOrder = getProjectOrder(serverId); if ( !hasVisibleOrderChanged({ currentOrder: currentProjectOrder, reorderedVisibleKeys: reorderedProjectKeys, }) ) { return; } setProjectOrder( serverId, mergeWithRemainder({ currentOrder: currentProjectOrder, reorderedVisibleKeys: reorderedProjectKeys, }), ); }, [getProjectOrder, serverId, setProjectOrder], ); const handleWorkspaceReorder = useCallback( (projectKey: string, reorderedWorkspaces: SidebarWorkspaceEntry[]) => { if (!serverId) { return; } const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); const currentWorkspaceOrder = getWorkspaceOrder(serverId, projectKey); if ( !hasVisibleOrderChanged({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, }) ) { return; } setWorkspaceOrder( serverId, projectKey, mergeWithRemainder({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, }), ); }, [getWorkspaceOrder, serverId, setWorkspaceOrder], ); const handleWorktreeCreated = useCallback((workspaceId: string) => { setCreatingWorkspaceIds((current) => { const next = new Set(current); next.add(workspaceId); return next; }); const existingTimeout = creatingWorkspaceTimeoutsRef.current.get(workspaceId); if (existingTimeout) { clearTimeout(existingTimeout); } creatingWorkspaceTimeoutsRef.current.set( workspaceId, setTimeout(() => { creatingWorkspaceTimeoutsRef.current.delete(workspaceId); setCreatingWorkspaceIds((current) => { if (!current.has(workspaceId)) { return current; } const next = new Set(current); next.delete(workspaceId); return next; }); }, 3000), ); }, []); const renderProject = useCallback( ({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo) => { return ( ); }, [ collapsedProjectKeys, activeWorkspaceSelection, handleWorktreeCreated, handleWorkspaceReorder, onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, projectIconByProjectKey, selectionEnabled, serverId, shortcutIndexByWorkspaceKey, showShortcutBadges, creatingWorkspaceIds, ], ); const content = ( <> {projects.length === 0 ? ( No projects yet Add a project to get started ) : ( )} {listFooterComponent} ); return ( {platformIsNative ? ( {content} ) : ( {content} )} ); } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, }, list: { flex: 1, }, listContent: { paddingHorizontal: theme.spacing[2], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, projectListContainer: { width: "100%", }, projectBlock: { marginBottom: theme.spacing[1], }, workspaceListContainer: {}, emptyContainer: { marginHorizontal: theme.spacing[2], marginTop: theme.spacing[4], paddingTop: theme.spacing[6], paddingBottom: theme.spacing[4], paddingHorizontal: theme.spacing[4], borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface0, alignItems: "center", gap: theme.spacing[3], }, emptyTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, textAlign: "center", }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, projectRow: { position: "relative", minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[1], flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], userSelect: "none", }, projectRowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, projectRowPressed: { backgroundColor: theme.colors.surface2, }, projectRowDragging: { backgroundColor: theme.colors.surface2, borderWidth: 1, borderColor: theme.colors.border, transform: [{ scale: 1.02 }], zIndex: 3, ...theme.shadow.md, }, projectRowLeft: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, }, projectTitleGroup: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flex: 1, minWidth: 0, }, projectIcon: { width: "100%", height: "100%", borderRadius: theme.borderRadius.sm, }, projectLeadingVisualSlot: { position: "relative", width: theme.iconSize.md, height: theme.iconSize.md, flexShrink: 0, alignItems: "center", justifyContent: "center", }, projectIconFallback: { width: "100%", height: "100%", borderRadius: theme.borderRadius.sm, alignItems: "center", justifyContent: "center", }, projectIconFallbackText: { fontSize: 9, }, projectTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: "400", minWidth: 0, flexShrink: 1, }, projectActionButton: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], borderRadius: theme.borderRadius.md, flexShrink: 0, }, projectActionButtonHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, projectActionButtonText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, projectIconActionButton: { width: 24, height: 24, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", flexShrink: 0, }, projectIconActionButtonHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, projectIconActionButtonHidden: { opacity: 0, }, projectTrailingActions: { flexDirection: "row", alignItems: "center", gap: 2, flexShrink: 0, }, projectKebabButton: { width: 24, height: 24, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", flexShrink: 0, }, projectKebabButtonHidden: { opacity: 0, }, projectKebabButtonHovered: { backgroundColor: theme.colors.surface2, }, projectTrailingControlSlot: { width: 24, height: 24, alignItems: "center", justifyContent: "center", flexShrink: 0, }, projectActionTooltipRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, projectActionTooltipText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, projectActionTooltipShortcut: {}, projectShortcutBadgeOverlay: { position: "absolute", top: theme.spacing[2] + 1, right: theme.spacing[2], }, workspaceRow: { minHeight: 36, marginBottom: theme.spacing[1], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[3] + theme.spacing[3], paddingRight: theme.spacing[3], borderRadius: theme.borderRadius.lg, flexDirection: "column", alignItems: "stretch", justifyContent: "center", gap: theme.spacing[1], userSelect: "none", }, workspaceRowMain: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], width: "100%", }, workspaceRowLeft: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, }, workspaceRowRight: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flexShrink: 0, }, workspaceRowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, workspaceRowPressed: { backgroundColor: theme.colors.surface2, }, workspaceRowDragging: { backgroundColor: theme.colors.surface2, borderWidth: 1, borderColor: theme.colors.border, transform: [{ scale: 1.02 }], zIndex: 3, ...theme.shadow.md, }, sidebarRowSelected: { backgroundColor: theme.colors.surfaceSidebarHover, }, workspaceRowContainer: { position: "relative", }, workspaceStatusDot: { position: "relative", width: WORKSPACE_STATUS_DOT_WIDTH, height: 16, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "center", justifyContent: "center", }, statusDotOverlay: { position: "absolute", right: DEFAULT_STATUS_DOT_OFFSET, bottom: DEFAULT_STATUS_DOT_OFFSET, width: DEFAULT_STATUS_DOT_SIZE, height: DEFAULT_STATUS_DOT_SIZE, borderRadius: theme.borderRadius.full, borderWidth: 1, }, workspaceArchivingOverlay: { ...StyleSheet.absoluteFillObject, borderRadius: theme.borderRadius.lg, backgroundColor: `${theme.colors.surface0}cc`, alignItems: "center", justifyContent: "center", flexDirection: "row", gap: theme.spacing[2], zIndex: 1, }, workspaceArchivingText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: "600", }, workspaceBranchText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: "400", lineHeight: 20, opacity: 0.76, flex: 1, minWidth: 0, }, workspaceBranchTextCreating: { opacity: 0.92, }, workspaceBranchTextHovered: { opacity: 1, }, workspacePrBadgeRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2], }, workspaceCreatingText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, flexShrink: 0, }, kebabButton: { padding: 2, borderRadius: 4, marginLeft: 2, }, kebabButtonHovered: { backgroundColor: theme.colors.surface2, }, statusDotNeedsInput: { backgroundColor: theme.colors.palette.amber[500], borderColor: theme.colors.surface0, }, statusDotFailed: { backgroundColor: theme.colors.palette.red[500], borderColor: theme.colors.surface0, }, statusDotRunning: { backgroundColor: theme.colors.palette.blue[500], borderColor: theme.colors.surface0, }, statusDotAttention: { backgroundColor: theme.colors.palette.green[500], borderColor: theme.colors.surface0, }, })); function getStatusDotColorStyle(bucket: SidebarStateBucket): ViewStyle | null { switch (bucket) { case "needs_input": return styles.statusDotNeedsInput; case "failed": return styles.statusDotFailed; case "running": return styles.statusDotRunning; case "attention": return styles.statusDotAttention; case "done": return null; } }