import { View, Text, Pressable, Image, Platform, ActivityIndicator, StatusBar, ScrollView, type GestureResponderEvent, } 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, } from "react"; import { router, usePathname } from "expo-router"; import { navigateToWorkspace } from "@/hooks/use-workspace-navigation"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; import { Archive, ChevronDown, ChevronRight, Copy, ExternalLink, FolderPlus, FolderGit2, GitPullRequest, Monitor, MoreVertical, Plus, } 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 { getIsElectronRuntime, isCompactFormFactor } from "@/constants/layout"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; import { type SidebarProjectEntry, type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; 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 { getStatusDotColor } from "@/utils/status-dot-color"; 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 { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useSessionStore } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; import { openExternalUrl } from "@/utils/open-external-url"; import { requireWorkspaceExecutionDirectory, resolveWorkspaceExecutionDirectory, } from "@/utils/workspace-execution"; 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; const EMPTY_WORKSPACES = new Map(); const WORKSPACE_STATUS_DOT_WIDTH = 14; const GITHUB_PR_STATE_LABELS: Record = { open: "Open", merged: "Merged", closed: "Closed", }; 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; 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 WorkspacePrBadge({ hint }: { hint: PrHint }) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted; const handlePressIn = useCallback((event: GestureResponderEvent) => { event.stopPropagation(); }, []); const handlePress = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); void openExternalUrl(hint.url); }, [hint.url], ); return ( setIsHovered(true)} onPointerLeave={() => setIsHovered(false)} style={({ pressed }) => [ styles.workspacePrBadge, pressed && styles.workspacePrBadgePressed, ]} > #{hint.number} ยท {GITHUB_PR_STATE_LABELS[hint.state]} {isHovered && } ); } function WorkspaceStatusIndicator({ bucket, workspaceKind, loading = false, }: { bucket: SidebarWorkspaceEntry["statusBucket"]; workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; loading?: boolean; }) { const { theme } = useUnistyles(); const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); if (loading) { return ( ); } if (shouldShowSyncedLoader) { return ( ); } const KindIcon = workspaceKind === "checkout" ? Monitor : workspaceKind === "worktree" ? FolderGit2 : null; if (!KindIcon) return null; const dotColor = getStatusDotColor({ theme, bucket, showDoneAsInactive: false }); return ( {dotColor ? ( ) : null} ); } 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 { theme } = useUnistyles(); 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 ( ); } const projectIcon = iconDataUri ? ( ) : ( {placeholderInitial} ); if (!shouldShowWorkspaceStatus || !activeWorkspace) { return {projectIcon}; } if (isArchiving) { return ( ); } if (shouldShowSyncedLoader) { return ( ); } const dotColor = getStatusDotColor({ theme, bucket: activeWorkspace.statusBucket, showDoneAsInactive: false, }); return ( {projectIcon} {dotColor ? ( ) : 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 { theme } = useUnistyles(); const newWorktreeKeys = useShortcutKeys("new-worktree"); return ( [ styles.projectIconActionButton, !visible && styles.projectIconActionButtonHidden, (hovered || pressed) && !loading && styles.projectIconActionButtonHovered, ]} onPress={(event) => { event.stopPropagation(); onPress(); }} disabled={loading} accessibilityRole="button" accessibilityLabel={`Create a new workspace for ${displayName}`} testID={testID} > {({ 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 || Platform.OS === "web") { 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.menuController, 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: any) => { 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, shortcutNumber = null, showShortcutBadge = false, drag, isDragging, isArchiving = false, menuController, dragHandleProps, }: ProjectHeaderRowProps) { const [isHovered, setIsHovered] = useState(false); const isMobileBreakpoint = isCompactFormFactor(); const beginWorkspaceSetup = useWorkspaceSetupStore((state) => state.beginWorkspaceSetup); const handleBeginWorkspaceSetup = useCallback(() => { if (!serverId) { return; } onWorkspacePress?.(); beginWorkspaceSetup({ serverId, sourceDirectory: project.iconWorkingDir, displayName, creationMethod: "create_worktree", navigationMethod: "navigate", }); }, [beginWorkspaceSetup, displayName, onWorkspacePress, project.iconWorkingDir, serverId]); useKeyboardActionHandler({ handlerId: `worktree-new-${project.projectKey}`, actions: ["worktree.new"], enabled: isProjectActive && canCreateWorktree, priority: 0, handle: () => { handleBeginWorkspaceSetup(); return true; }, }); const interaction = useLongPressDragInteraction({ drag, menuController, }); const handlePress = useCallback(() => { if (interaction.didLongPressRef.current) { interaction.didLongPressRef.current = false; return; } onPress(); }, [interaction.didLongPressRef, onPress]); const rowChildren = ( <> {displayName} {canCreateWorktree ? ( ) : null} {showShortcutBadge && shortcutNumber !== null ? ( {shortcutNumber} ) : null} ); if (menuController) { return ( setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> [ styles.projectRow, isDragging && styles.projectRowDragging, selected && styles.sidebarRowSelected, isHovered && styles.projectRowHovered, pressed && styles.projectRowPressed, ]} onPressIn={interaction.handlePressIn} onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} onPress={handlePress} testID={`sidebar-project-row-${project.projectKey}`} > {rowChildren} ); } return ( setIsHovered(true)} onPointerLeave={() => setIsHovered(false)}> [ styles.projectRow, isDragging && styles.projectRowDragging, selected && styles.sidebarRowSelected, isHovered && styles.projectRowHovered, pressed && styles.projectRowPressed, ]} onPressIn={interaction.handlePressIn} onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} onPress={handlePress} testID={`sidebar-project-row-${project.projectKey}`} > {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 { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const isTouchPlatform = Platform.OS !== "web"; 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]); return ( setIsHovered(true)} onPointerLeave={() => setIsHovered(false)} > [ styles.workspaceRow, isDragging && styles.workspaceRowDragging, selected && styles.sidebarRowSelected, isHovered && styles.workspaceRowHovered, pressed && styles.workspaceRowPressed, ]} onPressIn={interaction.handlePressIn} onTouchMove={interaction.handleTouchMove} onPressOut={interaction.handlePressOut} onPress={handlePress} testID={`sidebar-workspace-row-${workspace.workspaceKey}`} > {workspace.name} {isCreating ? Creating... : null} {onArchive && (isHovered || isTouchPlatform) ? ( [ styles.kebabButton, hovered && styles.kebabButtonHovered, ]} accessibilityRole="button" accessibilityLabel="Workspace actions" testID={`sidebar-workspace-kebab-${workspace.workspaceKey}`} > {({ hovered }) => ( )} {onCopyPath ? ( } onSelect={onCopyPath} > Copy path ) : null} {onCopyBranchName ? ( } onSelect={onCopyBranchName} > Copy branch name ) : null} } trailing={archiveShortcutKeys ? : null} status={archiveStatus} pendingLabel={archivePendingLabel} onSelect={onArchive} > {archiveLabel ?? "Archive"} ) : workspace.diffStat ? ( +{workspace.diffStat.additions} -{workspace.diffStat.deletions} ) : null} {showShortcutBadge && shortcutNumber !== null ? ( {shortcutNumber} ) : null} {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 = useNavigationActiveWorkspaceSelection(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); const sessionWorkspaces = useSessionStore( (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES, ); 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 ? archiveStatus === "pending" : isArchivingWorkspace; const redirectAfterArchive = useCallback(() => { if ( activeWorkspaceSelection?.serverId !== workspace.serverId || activeWorkspaceSelection.workspaceId !== workspace.workspaceId ) { return; } router.replace( buildWorkspaceArchiveRedirectRoute({ serverId: workspace.serverId, archivedWorkspaceId: workspace.workspaceId, workspaces: sessionWorkspaces.values(), }) as any, ); }, [activeWorkspaceSelection, sessionWorkspaces, 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 workspaceDirectory: string; try { workspaceDirectory = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); } catch (error) { toast.error(error instanceof Error ? error.message : "Workspace path not available"); return; } if (!workspaceDirectory) { toast.error("Workspace path not available"); return; } void archiveWorktree({ serverId: workspace.serverId, cwd: workspaceDirectory, worktreePath: workspaceDirectory, }) .then(() => { redirectAfterArchive(); }) .catch((error) => { const message = error instanceof Error ? error.message : "Failed to archive worktree"; toast.error(message); }); })(); }, [ archiveWorktree, isArchiving, redirectAfterArchive, toast, workspace.name, workspace.workspaceDirectory, 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); try { const payload = await client.archiveWorkspace(Number(workspace.workspaceId)); if (payload.error) { throw new Error(payload.error); } redirectAfterArchive(); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); } finally { setIsArchivingWorkspace(false); } })(); }, [ isArchivingWorkspace, redirectAfterArchive, toast, workspace.name, workspace.serverId, workspace.workspaceId, ]); const handleCopyPath = useCallback(() => { let workspaceDirectory: string; try { workspaceDirectory = requireWorkspaceExecutionDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); } catch (error) { toast.error(error instanceof Error ? error.message : "Workspace path not available"); return; } void Clipboard.setStringAsync(workspaceDirectory); 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 = useNavigationActiveWorkspaceSelection(); const sessionWorkspaces = useSessionStore( (state) => state.sessions[workspace.serverId]?.workspaces ?? EMPTY_WORKSPACES, ); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); const redirectAfterArchive = useCallback(() => { if ( activeWorkspaceSelection?.serverId !== workspace.serverId || activeWorkspaceSelection.workspaceId !== workspace.workspaceId ) { return; } router.replace( buildWorkspaceArchiveRedirectRoute({ serverId: workspace.serverId, archivedWorkspaceId: workspace.workspaceId, workspaces: sessionWorkspaces.values(), }) as any, ); }, [activeWorkspaceSelection, sessionWorkspaces, 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); try { const payload = await client.archiveWorkspace(Number(workspace.workspaceId)); if (payload.error) { throw new Error(payload.error); } redirectAfterArchive(); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to hide workspace"); } finally { setIsArchivingWorkspace(false); } })(); }, [ isArchivingWorkspace, redirectAfterArchive, toast, workspace.name, workspace.serverId, workspace.workspaceId, ]); 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, }: { 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; }) { if (project.projectKind === "directory") { return ( ); } return ( ); } function WorkspaceRow({ 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; }) { return ( ); } function ProjectBlock({ project, collapsed, displayName, iconDataUri, serverId, activeWorkspaceSelection, showShortcutBadges, shortcutIndexByWorkspaceKey, parentGestureRef, onToggleCollapsed, onWorkspacePress, onWorkspaceReorder, onWorktreeCreated, drag, isDragging, dragHandleProps, useNestable, creatingWorkspaceIds, }: { project: SidebarProjectEntry; collapsed: boolean; displayName: string; iconDataUri: string | null; serverId: string | null; activeWorkspaceSelection: { serverId: string; workspaceId: string } | null; showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; parentGestureRef?: MutableRefObject; onToggleCollapsed: () => void; onWorkspacePress?: () => void; onWorkspaceReorder: (projectKey: string, workspaces: SidebarWorkspaceEntry[]) => void; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; useNestable: boolean; creatingWorkspaceIds: ReadonlySet; }) { const rowModel = useMemo( () => buildSidebarProjectRowModel({ project, collapsed, serverId, activeWorkspaceSelection, }), [activeWorkspaceSelection, collapsed, project, serverId], ); const isProjectActive = useMemo(() => { if (!serverId || !activeWorkspaceSelection || activeWorkspaceSelection.serverId !== serverId) { return false; } return project.workspaces.some((w) => w.workspaceId === activeWorkspaceSelection.workspaceId); }, [serverId, activeWorkspaceSelection, project.workspaces]); const renderWorkspaceRow = useCallback( ( item: SidebarWorkspaceEntry, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps; }, ) => { const isSelected = Boolean(serverId) && activeWorkspaceSelection?.serverId === serverId && activeWorkspaceSelection.workspaceId === item.workspaceId; return ( { if (!serverId) { return; } onWorkspacePress?.(); navigateToWorkspace(serverId, item.workspaceId); }} drag={input?.drag ?? (() => {})} isDragging={input?.isDragging ?? false} dragHandleProps={input?.dragHandleProps} /> ); }, [ activeWorkspaceSelection, project.projectKind, creatingWorkspaceIds, onWorkspacePress, serverId, 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], ); return ( {rowModel.kind === "workspace_link" ? ( { if (!serverId) { return; } onWorkspacePress?.(); navigateToWorkspace(serverId, rowModel.workspace.workspaceId); }} serverId={serverId} onWorkspacePress={onWorkspacePress} onWorktreeCreated={onWorktreeCreated} shortcutNumber={shortcutIndexByWorkspaceKey.get(rowModel.workspace.workspaceKey) ?? null} showShortcutBadge={showShortcutBadges} drag={drag} isDragging={isDragging} dragHandleProps={dragHandleProps} isProjectActive={isProjectActive} /> ) : ( <> {!collapsed ? ( ) : null} )} ); } export function SidebarWorkspaceList({ projects, serverId, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, isRefreshing = false, onRefresh, onWorkspacePress, onAddProject, listFooterComponent, parentGestureRef, }: SidebarWorkspaceListProps) { const isMobile = isCompactFormFactor(); const isNative = Platform.OS !== "web"; const pathname = usePathname(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( new Map(), ); const isDesktopApp = getIsElectronRuntime(); const altDown = useKeyboardShortcutsStore((state) => state.altDown); const cmdOrCtrlDown = useKeyboardShortcutsStore((state) => state.cmdOrCtrlDown); const showShortcutBadges = altDown || (isDesktopApp && cmdOrCtrlDown); 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 effectiveActiveWorkspaceSelection = isWorkspaceRoute ? activeWorkspaceSelection : null; 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(() => { return () => { for (const timeout of creatingWorkspaceTimeoutsRef.current.values()) { clearTimeout(timeout); } creatingWorkspaceTimeoutsRef.current.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 ( onToggleProjectCollapsed(item.projectKey)} onWorkspacePress={onWorkspacePress} onWorkspaceReorder={handleWorkspaceReorder} onWorktreeCreated={handleWorktreeCreated} drag={drag} isDragging={isActive} dragHandleProps={dragHandleProps} useNestable={isNative} creatingWorkspaceIds={creatingWorkspaceIds} /> ); }, [ collapsedProjectKeys, effectiveActiveWorkspaceSelection, handleWorktreeCreated, handleWorkspaceReorder, onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, projectIconByProjectKey, serverId, shortcutIndexByWorkspaceKey, showShortcutBadges, isNative, creatingWorkspaceIds, ], ); const content = ( <> {projects.length === 0 ? ( No projects yet Add a project to get started ) : ( )} {listFooterComponent} ); return ( {isNative ? ( {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], }, projectRowHovered: { backgroundColor: theme.colors.surface1, }, 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.surface1, }, 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.surface1, }, projectIconActionButtonHidden: { opacity: 0, }, 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: { backgroundColor: theme.colors.surface3, borderColor: theme.colors.borderAccent, }, 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], }, 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.surface1, }, 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.surface1, }, 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: 0, bottom: 0, width: 7, height: 7, 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: { paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2], }, workspacePrBadge: { alignSelf: "flex-start", flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, workspacePrBadgePressed: { opacity: 0.82, }, workspacePrBadgeText: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, lineHeight: 14, }, workspaceCreatingText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, flexShrink: 0, }, diffStatRow: { flexDirection: "row", alignItems: "center", gap: 6, flexShrink: 0, }, diffStatAdditions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.palette.green[400], }, diffStatDeletions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.palette.red[500], }, 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, }, }));