import { View, Text, Pressable, Image, Platform, ActivityIndicator, StatusBar, ScrollView, type GestureResponderEvent, } from "react-native"; import * as Haptics from "expo-haptics"; import { useMutation, 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, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; import { Archive, Check, ChevronDown, ChevronRight, CircleHelp, Copy, FolderGit2, 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 { getIsDesktop } from "@/constants/layout"; import { projectIconQueryKey } from "@/hooks/use-project-icon-query"; import { parseHostWorkspaceRouteFromPathname } from "@/utils/host-routes"; import { prepareWorkspaceTab } from "@/utils/workspace-navigation"; import { type SidebarProjectEntry, type SidebarWorkspaceEntry, type SidebarStateBucket, } 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 { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { buildSidebarProjectRowModel } from "@/utils/sidebar-project-row-model"; import { useNavigationActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { createNameId } from "mnemonic-id"; import { buildWorkspaceArchiveRedirectRoute } from "@/utils/workspace-archive-navigation"; 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(); interface SidebarWorkspaceListProps { projects: SidebarProjectEntry[]; serverId: string | null; collapsedProjectKeys: ReadonlySet; onToggleProjectCollapsed: (projectKey: string) => void; shortcutIndexByWorkspaceKey: Map; isRefreshing?: boolean; onRefresh?: () => void; onWorkspacePress?: () => 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; 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; } function resolveStatusDotColor(input: { theme: ReturnType["theme"]; bucket: SidebarStateBucket; }) { const { theme, bucket } = input; return bucket === "needs_input" ? theme.colors.palette.amber[500] : bucket === "failed" ? theme.colors.palette.red[500] : bucket === "running" ? theme.colors.palette.blue[500] : bucket === "attention" ? theme.colors.palette.green[500] : theme.colors.border; } function WorkspaceStatusIndicator({ bucket, workspaceKind, loading = false, }: { bucket: SidebarWorkspaceEntry["statusBucket"]; workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; loading?: boolean; }) { const { theme } = useUnistyles(); const color = resolveStatusDotColor({ theme, bucket }); const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); const isIdle = !loading && !shouldShowSyncedLoader && bucket === "done"; if (isIdle) { const KindIcon = workspaceKind === "local_checkout" ? Monitor : workspaceKind === "worktree" ? FolderGit2 : null; if (!KindIcon) return null; return ( ); } return ( {loading ? ( ) : shouldShowSyncedLoader ? ( ) : bucket === "needs_input" ? ( ) : bucket === "attention" ? ( ) : ( )} ); } 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"); return ( {showChevron && chevron !== null ? ( ) : shouldShowWorkspaceStatus && activeWorkspace ? ( ) : iconDataUri ? ( ) : ( {placeholderInitial} )} ); } 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, }: { displayName: string; onPress: () => void; visible: boolean; loading?: boolean; testID: string; }) { const { theme } = useUnistyles(); return ( [ styles.projectIconActionButton, !visible && styles.projectIconActionButtonHidden, (hovered || pressed) && !loading && styles.projectIconActionButtonHovered, ]} onPress={(event) => { event.stopPropagation(); onPress(); }} disabled={loading} accessibilityRole="button" accessibilityLabel={`Create a new worktree for ${displayName}`} testID={testID} > {({ hovered, pressed }) => loading ? ( ) : ( ) } New worktree ); } 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, onWorkspacePress, onWorktreeCreated, shortcutNumber = null, showShortcutBadge = false, drag, isDragging, isArchiving = false, menuController, dragHandleProps, }: ProjectHeaderRowProps) { const [isHovered, setIsHovered] = useState(false); const isMobileBreakpoint = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const mergeWorkspaces = useSessionStore((state) => state.mergeWorkspaces); const toast = useToast(); const createWorktreeMutation = useMutation({ mutationFn: async () => { if (!serverId) { throw new Error("No server"); } const client = getHostRuntimeStore().getClient(serverId); if (!client || !isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(serverId))) { throw new Error("Host is not connected"); } const payload = await client.createPaseoWorktree({ cwd: project.iconWorkingDir, worktreeSlug: createNameId(), }); if (payload.error || !payload.workspace) { throw new Error(payload.error ?? "Failed to create worktree"); } return payload.workspace; }, onSuccess: (workspace) => { mergeWorkspaces(serverId!, [normalizeWorkspaceDescriptor(workspace)]); onWorktreeCreated?.(workspace.id); onWorkspacePress?.(); router.navigate( prepareWorkspaceTab({ serverId: serverId!, workspaceId: workspace.id, target: { kind: "draft", draftId: "new" }, }) as any, ); }, onError: (error) => { toast.error(error instanceof Error ? error.message : String(error)); }, }); 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 ? ( createWorktreeMutation.mutate()} visible={isHovered || isMobileBreakpoint} loading={createWorktreeMutation.isPending} testID={`sidebar-project-new-worktree-${project.projectKey}`} /> ) : 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, }: WorkspaceRowInnerProps) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const isMobile = Platform.OS !== "web"; 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 || isMobile) ? ( [ 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} } status={archiveStatus} pendingLabel={archivePendingLabel} onSelect={onArchive} > {archiveLabel ?? "Archive"} ) : workspace.diffStat ? ( +{workspace.diffStat.additions} -{workspace.diffStat.deletions} ) : null} {showShortcutBadge && shortcutNumber !== null ? ( {shortcutNumber} ) : 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 archiveStatus = useCheckoutGitActionsStore((state) => state.getStatus({ serverId: workspace.serverId, cwd: workspace.workspaceId, actionId: "archive-worktree", }), ); 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\nThis removes the worktree from the sidebar.`, confirmLabel: "Archive", cancelLabel: "Cancel", destructive: true, }); if (!confirmed) { return; } void archiveWorktree({ serverId: workspace.serverId, cwd: workspace.workspaceId, worktreePath: workspace.workspaceId, }) .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.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(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(() => { void Clipboard.setStringAsync(workspace.workspaceId); toast.copied("Path copied"); }, [toast, workspace.workspaceId]); const handleCopyBranchName = useCallback(() => { void Clipboard.setStringAsync(workspace.name); toast.copied("Branch name copied"); }, [toast, workspace.name]); 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(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, }: { 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; }) { if (project.projectKind === "non_git") { 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 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} /> ) : ( <> {!collapsed ? ( ) : null} )} ); } export function SidebarWorkspaceList({ projects, serverId, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, isRefreshing = false, onRefresh, onWorkspacePress, listFooterComponent, parentGestureRef, }: SidebarWorkspaceListProps) { const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; const isNative = Platform.OS !== "web"; const pathname = usePathname(); const activeWorkspaceSelection = useNavigationActiveWorkspaceSelection(); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( new Map(), ); const isDesktopApp = getIsDesktop(); 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 ) : ( )} {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: { marginLeft: theme.spacing[3], paddingLeft: theme.spacing[2], borderLeftWidth: StyleSheet.hairlineWidth, borderLeftColor: theme.colors.surface1, }, emptyText: { color: theme.colors.foregroundMuted, textAlign: "center", marginTop: theme.spacing[8], marginHorizontal: theme.spacing[2], }, projectRow: { minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], 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, elevation: 4, shadowColor: "#000", shadowOpacity: 0.2, shadowRadius: 8, shadowOffset: { width: 0, height: 4 }, }, 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: { 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, 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, }, projectActionTooltipText: { color: theme.colors.foreground, fontSize: theme.fontSize.xs, }, workspaceRow: { minHeight: 36, marginBottom: theme.spacing[1], paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.lg, flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], }, 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, elevation: 4, shadowColor: "#000", shadowOpacity: 0.2, shadowRadius: 8, shadowOffset: { width: 0, height: 4 }, }, sidebarRowSelected: { backgroundColor: theme.colors.surface1, }, workspaceRowContainer: { position: "relative", }, workspaceStatusDot: { width: 14, height: 16, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "flex-start", justifyContent: "center", }, workspaceStatusDotFill: { width: 7, height: 7, borderRadius: theme.borderRadius.full, }, 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, lineHeight: 20, opacity: 0.76, flex: 1, minWidth: 0, }, workspaceBranchTextCreating: { opacity: 0.92, }, workspaceBranchTextHovered: { opacity: 1, }, 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, }, }));