import { View, Text, Pressable, Image, Platform, ActivityIndicator, StatusBar, ScrollView, type GestureResponderEvent, type PressableStateCallbackType, type ViewStyle, } from "react-native"; import * as Haptics from "expo-haptics"; import { useQueries } from "@tanstack/react-query"; import { useCallback, useMemo, useState, useEffect, useRef, type ReactElement, type MutableRefObject, type Ref, } from "react"; import { router, usePathname, type Href } from "expo-router"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; 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, ChevronDown, ChevronRight, Copy, ExternalLink, FolderPlus, FolderGit2, GitPullRequest, Globe, Settings, SquareTerminal, Monitor, MoreVertical, 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, isHostRuntimeConnected } from "@/runtime/host-runtime"; import { useIsCompactFormFactor } from "@/constants/layout"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; 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 "@/stores/checkout-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 { 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 { type PrHint, useWorkspacePrHint } from "@/hooks/use-checkout-pr-status-query"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store"; import { useWorkspaceFields } from "@/stores/session-store-hooks"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { clearWorkspaceArchivePending, markWorkspaceArchivePending, } from "@/contexts/session-workspace-upserts"; import { openExternalUrl } from "@/utils/open-external-url"; import { requireWorkspaceExecutionDirectory, resolveWorkspaceMapKeyByIdentity, resolveWorkspaceExecutionDirectory, } from "@/utils/workspace-execution"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { GitHubIcon } from "@/components/icons/github-icon"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null { if (!icon) { return null; } return `data:${icon.mimeType};base64,${icon.data}`; } const workspaceKeyExtractor = (workspace: SidebarWorkspaceEntry) => workspace.workspaceKey; const projectKeyExtractor = (project: SidebarProjectEntry) => project.projectKey; function hideWorkspaceOptimistically(workspace: SidebarWorkspaceEntry): WorkspaceDescriptor | null { const workspaces = useSessionStore.getState().sessions[workspace.serverId]?.workspaces; const workspaceKey = resolveWorkspaceMapKeyByIdentity({ workspaces, workspaceId: workspace.workspaceId, }); const snapshot = workspaceKey ? (workspaces?.get(workspaceKey) ?? null) : null; markWorkspaceArchivePending({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); useSessionStore.getState().removeWorkspace(workspace.serverId, workspace.workspaceId); return snapshot; } function restoreOptimisticallyHiddenWorkspace(input: { serverId: string; workspaceId: string; snapshot: WorkspaceDescriptor | null; }): void { clearWorkspaceArchivePending({ serverId: input.serverId, workspaceId: input.workspaceId, }); if (input.snapshot) { useSessionStore.getState().mergeWorkspaces(input.serverId, [input.snapshot]); } } 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 ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedMonitor = withUnistyles(Monitor); const ThemedFolderGit2 = withUnistyles(FolderGit2); const ThemedFolderPlus = withUnistyles(FolderPlus); const ThemedGlobe = withUnistyles(Globe); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedTrash2 = withUnistyles(Trash2); const ThemedSettings = withUnistyles(Settings); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); 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 blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[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; } } interface SidebarWorkspaceListProps { projects: SidebarProjectEntry[]; serverId: string | null; collapsedProjectKeys: ReadonlySet; onToggleProjectCollapsed: (projectKey: string) => void; shortcutIndexByWorkspaceKey: Map; 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; 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"; } 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 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 ChecksBadge({ checks }: { checks: PrHint["checks"] }): ReactElement | null { if (!checks || checks.length === 0) return null; const failed = checks.filter((c) => c.status === "failure").length; if (failed === 0) return null; const label = `${failed} failed`; return ( {label} ); } const checksBadgeStyles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", alignItems: "center", gap: 2, }, text: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, lineHeight: 14, color: theme.colors.palette.red[500], }, })); function WorkspaceStatusIndicator({ bucket, workspaceKind, loading = false, }: { bucket: SidebarWorkspaceEntry["statusBucket"]; workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; loading?: boolean; }) { const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); if (loading) { return ( ); } if (shouldShowSyncedLoader) { return ( ); } if (bucket === "needs_input") { return ( ); } let KindIcon: typeof ThemedMonitor | null; if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; else KindIcon = null; if (!KindIcon) return null; const dotColorStyle = getStatusDotColorStyle(bucket); const statusDotSize = isEmphasizedStatusDotBucket(bucket) ? 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 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, chevron = null, showChevron = false, isArchiving = false, }: { displayName: string; iconDataUri: string | null; workspace: SidebarWorkspaceEntry | null; 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 archiveLeadingIcon = ; 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, showScriptsIcon, hasRunningService, isCreating, showShortcutBadge, shortcutNumber, archiveLabel, archiveStatus, archivePendingLabel, archiveShortcutKeys, onArchive, onCopyBranchName, onCopyPath, }: { workspace: SidebarWorkspaceEntry; isHovered: boolean; isTouchPlatform: boolean; showScriptsIcon: boolean; hasRunningService: boolean; isCreating: boolean; showShortcutBadge: boolean; shortcutNumber: number | null; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; }) { const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); return ( {showScriptsIcon ? ( {hasRunningService ? ( ) : ( )} ) : null} {isCreating ? Creating... : null} {showKebab && onArchive ? ( ) : null} {!showKebab && workspace.diffStat ? ( ) : null} {showShortcutBadge && shortcutNumber !== null ? ( {shortcutNumber} ) : null} ); } function WorkspaceKebabMenu({ workspaceKey, onCopyPath, onCopyBranchName, onArchive, archiveLabel, archiveStatus, archivePendingLabel, archiveShortcutKeys, }: { workspaceKey: string; onCopyPath?: () => void; onCopyBranchName?: () => 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} {archiveLabel ?? "Archive"} ); } function ProjectIcon({ iconDataUri, placeholderInitial, }: { iconDataUri: string | null; placeholderInitial: string; }) { const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]); if (iconDataUri) { return ; } return ( {placeholderInitial} ); } function ProjectLeadingVisualStatus({ iconDataUri, placeholderInitial, isArchiving, shouldShowSyncedLoader, activeWorkspace, }: { iconDataUri: string | null; placeholderInitial: 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 }) as Href, ); onWorkspacePress?.(); }, [displayName, onWorkspacePress, project.iconWorkingDir, serverId]); const _mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const _toast = useToast(); const interaction = useLongPressDragInteraction({ drag, menuController, }); 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 ? ( {shortcutNumber} ) : 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, archiveShortcutKeys, }: WorkspaceRowInnerProps) { const _isCompact = useIsCompactFormFactor(); const [isHovered, setIsHovered] = useState(false); const isTouchPlatform = platformIsNative; const workspaceDirectory = resolveWorkspaceExecutionDirectory({ workspaceDirectory: workspace.workspaceDirectory, }); const prHint = useWorkspacePrHint({ serverId: workspace.serverId, cwd: workspaceDirectory ?? "", enabled: workspace.projectKind === "git" && Boolean(workspaceDirectory), }); const interaction = useLongPressDragInteraction({ drag, menuController, }); 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 workspaceRowStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ styles.workspaceRow, isDragging && styles.workspaceRowDragging, selected && styles.sidebarRowSelected, isHovered && styles.workspaceRowHovered, pressed && styles.workspaceRowPressed, ], [isDragging, selected, isHovered], ); const isDesktop = !isTouchPlatform; const showScriptsIcon = isDesktop && workspace.hasRunningScripts; const hasRunningService = workspace.scripts.some( (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", ); const accessibilityState = useMemo(() => ({ selected }), [selected]); const workspaceBranchTextStyle = useMemo( () => [ styles.workspaceBranchText, isHovered && styles.workspaceBranchTextHovered, isCreating && styles.workspaceBranchTextCreating, ], [isHovered, isCreating], ); return ( } style={styles.workspaceRowContainer} onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} > {workspace.name} {prHint ? ( ) : null} ); } 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 activeWorkspaceSelection = useActiveWorkspaceSelection(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const [isArchivingWorkspace, setIsArchivingWorkspace] = 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, }); }, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]); const handleArchiveWorktree = useCallback(() => { if (isArchiving) { return; } void (async () => { const confirmed = await confirmDialog({ title: "Archive worktree?", message: `Archive "${workspace.name}"?\n\nThe worktree will be removed from disk, terminals will be stopped, and agents inside will be archived.\n\nYour branch is still accessible if you committed.`, confirmLabel: "Archive", cancelLabel: "Cancel", destructive: true, }); 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 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); const snapshot = hideWorkspaceOptimistically(workspace); redirectAfterArchive(); void (async () => { try { const payload = await client.archiveWorkspace(workspace.workspaceId); if (payload.error) { throw new Error(payload.error); } } catch (error) { restoreOptimisticallyHiddenWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, snapshot, }); toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); } finally { setIsArchivingWorkspace(false); } })(); })(); }, [isArchivingWorkspace, redirectAfterArchive, toast, workspace]); 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 archiveShortcutKeys = useShortcutKeys("archive-worktree"); useKeyboardActionHandler({ handlerId: `worktree-archive-${workspace.workspaceKey}`, actions: ["worktree.archive"], enabled: selected && !isArchiving, priority: 0, handle: () => { if (isWorktree) { handleArchiveWorktree(); } 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 activeWorkspaceSelection = useActiveWorkspaceSelection(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const redirectAfterArchive = useCallback(() => { redirectIfArchivingActiveWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, activeWorkspaceSelection, }); }, [activeWorkspaceSelection, workspace.serverId, workspace.workspaceId]); 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); const snapshot = hideWorkspaceOptimistically(workspace); redirectAfterArchive(); void (async () => { try { const payload = await client.archiveWorkspace(workspace.workspaceId); if (payload.error) { throw new Error(payload.error); } } catch (error) { restoreOptimisticallyHiddenWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, snapshot, }); 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, }: { 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; }) { const workspace = useSidebarWorkspaceEntry(serverId, rowModel.workspace.workspaceId); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const selected = selectionEnabled && activeWorkspaceSelection?.serverId === serverId && activeWorkspaceSelection.workspaceId === rowModel.workspace.workspaceId; 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; currentPathname: string | null; onWorkspacePress?: () => void; drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps; } function WorkspaceRowItem({ workspace, shortcutNumber, showShortcutBadge, canCopyBranchName, isCreating = false, selectionEnabled, serverId, currentPathname, onWorkspacePress, drag, isDragging = false, dragHandleProps, }: WorkspaceRowItemProps) { const handlePress = useCallback(() => { if (!serverId) { return; } onWorkspacePress?.(); navigateToWorkspace(serverId, workspace.workspaceId, { currentPathname }); }, [serverId, onWorkspacePress, workspace.workspaceId, currentPathname]); return ( ); } function WorkspaceRow({ workspace, shortcutNumber, showShortcutBadge, onPress, drag, isDragging, dragHandleProps, canCopyBranchName, isCreating = false, selectionEnabled, }: { workspace: SidebarWorkspaceEntry; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; isCreating?: boolean; selectionEnabled: boolean; }) { const hydratedWorkspace = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const selected = selectionEnabled && activeWorkspaceSelection?.serverId === workspace.serverId && activeWorkspaceSelection.workspaceId === workspace.workspaceId; if (!hydratedWorkspace) { return null; } return ( ); } function ProjectBlock({ project, collapsed, displayName, iconDataUri, serverId, selectionEnabled, showShortcutBadges, shortcutIndexByWorkspaceKey, parentGestureRef, onToggleCollapsed, onWorkspacePress, onWorkspaceReorder, onWorktreeCreated, currentPathname, drag, isDragging, dragHandleProps, useNestable, creatingWorkspaceIds, }: { 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; currentPathname: string | null; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; useNestable: boolean; creatingWorkspaceIds: ReadonlySet; }) { const rowModel = useMemo( () => buildSidebarProjectRowModel({ project, collapsed, }), [collapsed, project], ); const projectWorkspaceIds = useMemo( () => project.workspaces.map((workspace) => workspace.workspaceId), [project.workspaces], ); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const isProjectActive = selectionEnabled && activeWorkspaceSelection?.serverId === serverId && projectWorkspaceIds.includes(activeWorkspaceSelection.workspaceId); const renderWorkspaceRow = useCallback( ( item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps; }, ) => { return ( ); }, [ project.projectKind, creatingWorkspaceIds, currentPathname, 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); const snapshots = new Map( project.workspaces.map((workspace) => [ workspace.workspaceId, hideWorkspaceOptimistically(workspace), ]), ); const isRejected = (r: PromiseSettledResult) => r.status === "rejected"; void Promise.allSettled( project.workspaces.map(async (ws) => { try { const payload = await client.archiveWorkspace(ws.workspaceId); if (payload.error) { throw new Error(payload.error); } } catch (error) { restoreOptimisticallyHiddenWorkspace({ serverId, workspaceId: ws.workspaceId, snapshot: snapshots.get(ws.workspaceId) ?? null, }); throw error; } }), ).then((results) => { const failed = results.filter(isRejected); if (failed.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, { currentPathname, }); }, [serverId, flattenedRowWorkspaceId, onWorkspacePress, currentPathname]); const handleToggleCollapsed = useCallback(() => { onToggleCollapsed(project.projectKey); }, [onToggleCollapsed, project.projectKey]); return ( {rowModel.kind === "workspace_link" ? ( ) : ( <> {!collapsed ? ( ) : null} )} ); } export function SidebarWorkspaceList({ projects, serverId, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, isRefreshing: _isRefreshing = false, onRefresh: _onRefresh, onWorkspacePress, onAddProject, listFooterComponent, parentGestureRef, }: SidebarWorkspaceListProps) { const pathname = usePathname(); 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 projectIconRequests = useMemo(() => { if (!serverId) { return []; } const unique = new Map(); for (const project of projects) { const cwd = project.iconWorkingDir.trim(); if (!cwd) { continue; } unique.set(`${serverId}:${cwd}`, { serverId, cwd }); } return Array.from(unique.values()); }, [projects, serverId]); const projectIconQueries = useQueries({ queries: projectIconRequests.map((request) => ({ queryKey: projectIconQueryKey(request.serverId, request.cwd), queryFn: async () => { const client = getHostRuntimeStore().getClient(request.serverId); if (!client) { return null; } const result = await client.requestProjectIcon(request.cwd); return result.icon; }, select: toProjectIconDataUri, enabled: Boolean( getHostRuntimeStore().getClient(request.serverId) && isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)) && request.cwd, ), staleTime: Infinity, gcTime: 1000 * 60 * 60, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, })), }); const projectIconByProjectKey = useMemo(() => { const iconByServerAndCwd = new Map(); for (let index = 0; index < projectIconRequests.length; index += 1) { const request = projectIconRequests[index]; if (!request) { continue; } iconByServerAndCwd.set( `${request.serverId}:${request.cwd}`, projectIconQueries[index]?.data ?? null, ); } const byProject = new Map(); for (const project of projects) { const cwd = project.iconWorkingDir.trim(); if (!cwd || !serverId) { byProject.set(project.projectKey, null); continue; } byProject.set(project.projectKey, iconByServerAndCwd.get(`${serverId}:${cwd}`) ?? null); } return byProject; }, [projectIconQueries, projectIconRequests, projects, serverId]); 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, handleWorktreeCreated, handleWorkspaceReorder, onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, pathname, 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: { 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, borderWidth: 1, borderColor: theme.colors.border, alignItems: "center", justifyContent: "center", }, projectIconFallbackText: { color: theme.colors.foregroundMuted, 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: {}, 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, }, shortcutBadge: { minWidth: 18, height: 18, paddingHorizontal: theme.spacing[1], alignItems: "center", justifyContent: "center", borderRadius: theme.borderRadius.sm, borderWidth: 1, borderColor: theme.colors.surface2, backgroundColor: theme.colors.surface0, flexShrink: 0, }, shortcutBadgeText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.medium, lineHeight: 14, }, 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; } }