import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, Text, Pressable, ScrollView, type GestureResponderEvent, type PressableStateCallbackType, type StyleProp, type ViewStyle, } from "react-native"; import { useMutation } from "@tanstack/react-query"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { memo, useCallback, useMemo, useState, useEffect, useRef, type ReactElement, type MutableRefObject, type Ref, type ComponentProps, type PropsWithChildren, } from "react"; import { useTranslation } from "react-i18next"; import { router, usePathname, type Href } from "expo-router"; import { navigateToWorkspace, useActiveWorkspaceSelection, type ActiveWorkspaceSelection, } from "@/stores/navigation-active-workspace-store"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; import { DiffStat } from "@/components/diff-stat"; import { ExternalLink, GitPullRequest, Settings, 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, useHosts } from "@/runtime/host-runtime"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { useSidebarWorkspacePinController, type ToggleSidebarWorkspacePin, } from "@/hooks/use-sidebar-workspace-pin"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; import { useHostFeatureMap } from "@/runtime/host-features"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useProjectIcons } from "@/projects/icons"; import { buildNewWorkspaceRoute, buildProjectSettingsRoute, parseHostWorkspaceRouteFromPathname, } from "@/utils/host-routes"; import { shouldShowSidebarHostLabels, useSidebarProjectStatusBucket, type SidebarProjectEntry, type SidebarWorkspaceEntry, type SidebarWorkspacePlacement, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarOrderStore } from "@/stores/sidebar-order-store"; import { useSidebarViewStore } from "@/stores/sidebar-view-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 { ProjectLeadingVisual } from "@/components/sidebar/project-leading-visual"; import { useToast } from "@/contexts/toast-context"; import { getForgePresentation, normalizeForge } from "@/git/forge"; import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import { hasVisibleOrderChanged, mergeWithRemainder } from "@/utils/sidebar-reorder"; import { confirmDialog } from "@/utils/confirm-dialog"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { SidebarStatusWorkspaceList } from "@/components/sidebar/sidebar-status-list"; import type { StatusGroup } from "@/hooks/sidebar-status-view-model"; import { SidebarWorkspaceContextMenu, SidebarWorkspaceMenu, } from "@/components/sidebar/sidebar-workspace-menu"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { PinnedSectionHeader } from "@/components/sidebar/pinned-section-header"; import { SidebarGroupToggleRow } from "@/components/sidebar/sidebar-group-toggle-row"; import { useLimitedSidebarGroup } from "@/components/sidebar/use-limited-sidebar-group"; import { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, SidebarWorkspaceShortcutBadge, resolveTrailingActionVisibility, SidebarWorkspaceTrailingActionBase, SidebarWorkspaceTrailingActionOverlay, SidebarWorkspaceTrailingActionSlot, } from "@/components/sidebar/sidebar-workspace-row-content"; import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler"; import { useClearWorkspaceAttention } from "@/hooks/use-clear-workspace-attention"; import type { PrHint } from "@/git/use-pr-status-query"; import { buildSidebarProjectRowModel, resolveSidebarProjectIconTargets, resolveSidebarProjectLocalPath, type SidebarProjectHostTarget, } from "@/utils/sidebar-project-row-model"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { openExternalUrl } from "@/utils/open-external-url"; import { requireWorkspaceDirectory } from "@/utils/workspace-directory"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { getCurrentProjectRemoveReadiness, removeProjectFromHosts, } from "@/projects/project-remove"; import { isWeb as platformIsWeb, isNative as platformIsNative, getIsElectron, } from "@/constants/platform"; import { getDesktopHost } from "@/desktop/host"; import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item"; import { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon"; import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance"; const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey; const projectViewKeyExtractor = (project: SidebarProjectEntry) => project.viewKey; const WORKSPACE_STATUS_DOT_WIDTH = 14; const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const ThemedPlus = withUnistyles(Plus); const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedTrash2 = withUnistyles(Trash2); const ThemedSettings = withUnistyles(Settings); 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 greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500], }); const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500], }); function getPrIconUniMapping(state: PrHint["state"]) { switch (state) { case "merged": return purpleColorMapping; case "open": return greenColorMapping; case "closed": return redColorMapping; } } function isWorkspaceSelected(input: { selection: ActiveWorkspaceSelection | null; serverId: string | null; workspaceId: string; enabled: boolean; }): boolean { return ( input.enabled && input.selection?.serverId === input.serverId && input.selection.workspaceId === input.workspaceId ); } function isProjectSelectedByRoute(input: { selection: ActiveWorkspaceSelection | null; project: SidebarProjectEntry; enabled: boolean; }): boolean { return ( input.enabled && input.project.workspaces.some( (workspace) => workspace.serverId === input.selection?.serverId && workspace.workspaceId === input.selection.workspaceId, ) ); } function activeWorkspaceSelectionKey(selection: ActiveWorkspaceSelection | null): string { return selection ? `${selection.serverId}:${selection.workspaceId}` : ""; } function selectionForSelectedWorkspace( selected: boolean, workspace: SidebarWorkspaceEntry, ): ActiveWorkspaceSelection | null { return selected ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } : null; } interface SidebarWorkspaceListProps { statusGroups: StatusGroup[]; pinnedGroups: PinnedSidebarGroups; projects: SidebarProjectEntry[]; workspaceEntriesByKey: ReadonlyMap; collapsedProjectKeys: ReadonlySet; onToggleProjectCollapsed: (projectViewKey: string) => void; shortcutIndexByWorkspaceKey: Map; groupMode: "project" | "status"; isRefreshing?: boolean; onRefresh?: () => void; onWorkspacePress?: () => void; onAddProject?: () => void; listFooterComponent?: ReactElement | null; // Rendered inside the scroll area, below the Pinned section and above the workspace // list. Holds the "Workspaces" section header so pinned items sit above it. listHeaderComponent?: ReactElement | null; /** Gesture ref for coordinating with parent gestures (e.g., sidebar close) */ parentGestureRef?: MutableRefObject; dragGestureHostPresented?: boolean; } interface ProjectHeaderRowProps { project: SidebarProjectEntry; displayName: string; iconDataUri: string | null; statusBucket: SidebarStateBucket | null; selected?: boolean; chevron: "expand" | "collapse" | null; onPress: () => void; worktreeTarget: SidebarProjectHostTarget | null; 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; hostBadge?: HostBadgeModel | null; leadingProjectName?: string | null; leadingProjectIconDataUri?: string | null; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; drag: () => void; isDragging: boolean; isArchiving: boolean; isCreating?: boolean; dragHandleProps?: DraggableListDragHandleProps; menuController: ReturnType | null; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; isPinned?: boolean; onTogglePin?: () => void; reserveIdleStatusIndicatorSpace?: boolean; } export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp }) { const { t } = useTranslation(); 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), []); // Callers that place the badge in a list of icon+text rows pass that row's layout in, so the // icon and text land on the same rails as their neighbors instead of on the badge's tighter // inline spacing. const pressableStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ prBadgeStyles.badge, style, pressed && prBadgeStyles.badgePressed, ], [style], ); const textStyle = isHovered ? [prBadgeStyles.text, prBadgeStyles.textHovered] : prBadgeStyles.text; const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state); const presentation = getForgePresentation(normalizeForge(hint.forge)); return ( {isHovered ? ( ) : ( )} {presentation.numberPrefix} {hint.number} ); } function projectKebabStyle({ hovered = false, }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.projectKebabButton, hovered && styles.projectKebabButtonHovered]; } function getProjectWorkspaceRowStyle({ isDragging, selected, isHovered, }: { isDragging: boolean; selected: boolean; isHovered: boolean; }) { return [ styles.workspaceRow, isDragging && styles.workspaceRowDragging, selected && styles.sidebarRowSelected, isHovered && styles.workspaceRowHovered, ]; } function noop() {} const prBadgeStyles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", alignItems: "center", gap: 2, }, badgePressed: { opacity: 0.82, }, text: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, lineHeight: 14, color: theme.colors.foregroundMuted, }, textHovered: { color: theme.colors.foreground, }, })); function ProjectRowTrailingActions({ projectViewKey, displayName, worktreeTarget, settingsTarget, projectPath, isHovered, isMobileBreakpoint, isProjectActive, onBeginWorkspaceSetup, onRemoveProject, removeProjectStatus, }: { projectViewKey: string; displayName: string; worktreeTarget: SidebarProjectHostTarget | null; settingsTarget: { serverId: string; projectId: string } | null; projectPath: string; isHovered: boolean; isMobileBreakpoint: boolean; isProjectActive: boolean; onBeginWorkspaceSetup: () => void; onRemoveProject?: () => void; removeProjectStatus: "idle" | "pending" | "success"; }) { const actionsVisible = isHovered || platformIsNative || isMobileBreakpoint; return ( {worktreeTarget ? ( ) : null} {onRemoveProject ? ( ) : null} ); } const trash2LeadingIcon = ; const settingsLeadingIcon = ; const openInNewWindowLeadingIcon = ( ); function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( ); } function ProjectKebabMenu({ projectViewKey, settingsTarget, projectPath, onRemoveProject, removeProjectStatus, }: { projectViewKey: string; settingsTarget: { serverId: string; projectId: string } | null; projectPath: string; onRemoveProject: () => void; removeProjectStatus: "idle" | "pending" | "success"; }) { const { t } = useTranslation(); return ( {renderKebabTriggerIcon} ); } type ProjectMenuSurface = "context" | "dropdown"; function ProjectMenuItem({ surface, children, ...props }: PropsWithChildren< Omit, "children"> & { surface: ProjectMenuSurface } >) { if (surface === "context") { return {children}; } return {children}; } function ProjectMenuItems({ surface, projectViewKey, settingsTarget, projectPath, onRemoveProject, removeProjectStatus, }: { surface: ProjectMenuSurface; projectViewKey: string; settingsTarget: { serverId: string; projectId: string } | null; projectPath: string; onRemoveProject: () => void; removeProjectStatus: "idle" | "pending" | "success"; }) { const { t } = useTranslation(); const toast = useToast(); const handleOpenProjectSettings = useCallback(() => { if (!settingsTarget) return; router.navigate(buildProjectSettingsRoute(settingsTarget.serverId, settingsTarget.projectId)); }, [settingsTarget]); const canOpenInNewWindow = getIsElectron() && projectPath.trim().length > 0; const handleOpenInNewWindow = useCallback(() => { const trimmedPath = projectPath.trim(); if (trimmedPath.length === 0) return; void getDesktopHost() ?.window?.openNew?.({ pendingOpenProjectPath: trimmedPath }) ?.catch((error) => { console.warn("[sidebar] openNew failed", error); toast.error(t("sidebar.project.actions.openNewWindowFailed")); }); }, [projectPath, t, toast]); return ( <> {settingsTarget ? ( {t("sidebar.project.actions.openSettings")} ) : null} {canOpenInNewWindow ? ( {t("sidebar.project.actions.openNewWindow")} ) : null} {t("sidebar.project.actions.remove")} ); } function WorkspaceRowRightGroup({ workspace, isHovered, isTouchPlatform, isCreating, showShortcutBadge, shortcutNumber, archiveLabel, archiveStatus, archivePendingLabel, archiveShortcutKeys, onArchive, onMarkAsRead, onCopyBranchName, onCopyPath, onRename, isPinned, onTogglePin, }: { workspace: SidebarWorkspaceEntry; isHovered: boolean; isTouchPlatform: boolean; isCreating: boolean; showShortcutBadge: boolean; shortcutNumber: number | null; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; onArchive?: () => void; onMarkAsRead?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; isPinned?: boolean; onTogglePin?: () => void; }) { const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath; const { t } = useTranslation(); const showShortcut = showShortcutBadge && shortcutNumber !== null; const { showDiffStat, showKebab: showKebabInSlot, showScrim, } = resolveTrailingActionVisibility({ hasDiffStat: Boolean(workspace.diffStat), hasArchiveAction: Boolean(onArchive), isHovered, isTouchPlatform, showShortcut, }); const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); return ( <> {isCreating ? ( {t("sidebar.workspace.status.creating")} ) : null} {shouldRenderActionSlot ? ( {workspace.diffStat ? ( ) : null} {onArchive ? ( ) : null} ) : null} ); } function NewWorktreeButton({ displayName, onPress, visible, loading = false, testID, showShortcutHint = false, }: { displayName: string; onPress: () => void; visible: boolean; loading?: boolean; testID: string; showShortcutHint?: boolean; }) { const { t } = useTranslation(); 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 ? ( ) : ( ) } {t("sidebar.workspace.actions.newWorkspace")} {showShortcutHint && newWorktreeKeys ? ( ) : null} ); } function NewWorkspaceGhostRow({ project, displayName, worktreeTarget, onWorkspacePress, }: { project: SidebarProjectEntry; displayName: string; worktreeTarget: SidebarProjectHostTarget; onWorkspacePress?: () => void; }) { const { t } = useTranslation(); const handlePress = useCallback(() => { onWorkspacePress?.(); router.navigate( buildNewWorkspaceRoute({ serverId: worktreeTarget.serverId, sourceDirectory: worktreeTarget.iconWorkingDir, displayName, projectId: worktreeTarget.projectId, }) as Href, ); }, [displayName, onWorkspacePress, worktreeTarget]); const rowStyle = useCallback( ({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [ styles.newWorkspaceGhostRow, (Boolean(hovered) || pressed) && styles.newWorkspaceGhostRowHovered, ], [], ); return ( {({ hovered, pressed }) => ( <> {t("sidebar.workspace.actions.newWorkspace")} )} ); } function ProjectHeaderRow({ project, displayName, iconDataUri, statusBucket, selected = false, chevron, onPress, worktreeTarget, 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 [contextMenuOpen, setContextMenuOpen] = useState(false); const isMobileBreakpoint = useIsCompactFormFactor(); const localDaemonServerId = useLocalDaemonServerId(); const projectPath = resolveSidebarProjectLocalPath(project, localDaemonServerId); const settingsTarget = project.hosts[0] ?? null; const handleBeginWorkspaceSetup = useCallback(() => { if (!worktreeTarget) { return; } onWorkspacePress?.(); router.navigate( buildNewWorkspaceRoute({ serverId: worktreeTarget.serverId, sourceDirectory: worktreeTarget.iconWorkingDir, displayName, projectId: worktreeTarget.projectId, }) as Href, ); }, [displayName, onWorkspacePress, worktreeTarget]); const interaction = useLongPressDragInteraction({ drag, menuController, }); const { role: _dragRole, tabIndex: _dragTabIndex, "aria-roledescription": _dragRoleDescription, ...dragAttributes } = dragHandleProps?.attributes ?? {}; const handlePress = useCallback(() => { if (interaction.didLongPressRef.current) { interaction.didLongPressRef.current = false; return; } onPress(); }, [interaction.didLongPressRef, onPress]); const handlePointerEnter = useCallback(() => { if (!contextMenuOpen) setIsHovered(true); }, [contextMenuOpen]); const handlePointerLeave = useCallback(() => setIsHovered(false), []); const handleContextMenuOpenChange = useCallback((open: boolean) => { setContextMenuOpen(open); if (open) setIsHovered(false); }, []); const projectRowStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ styles.projectRow, isDragging && styles.projectRowDragging, selected && styles.sidebarRowSelected, isHovered && styles.projectRowHovered, pressed && styles.projectRowPressed, ], [isDragging, selected, isHovered], ); const rowChildren = ( <> {displayName} {showShortcutBadge && shortcutNumber !== null ? ( ) : null} ); if (!onRemoveProject) { return ( } onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} > {rowChildren} ); } return ( } onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave} > {rowChildren} ); } function WorkspaceRowInner({ workspace, hostBadge, leadingProjectName, leadingProjectIconDataUri, selected, shortcutNumber, showShortcutBadge, onPress, drag, isDragging, isArchiving, isCreating = false, dragHandleProps, menuController, archiveLabel, archiveStatus = "idle", archivePendingLabel, onArchive, onCopyBranchName, onCopyPath, onRename, archiveShortcutKeys, isPinned, onTogglePin, reserveIdleStatusIndicatorSpace = true, }: WorkspaceRowInnerProps) { const _isCompact = useIsCompactFormFactor(); const isTouchPlatform = platformIsNative; const interaction = useLongPressDragInteraction({ drag, menuController, }); const { role: _dragRole, tabIndex: _dragTabIndex, "aria-roledescription": _dragRoleDescription, ...dragAttributes } = dragHandleProps?.attributes ?? {}; const handlePress = useCallback(() => { if (interaction.didLongPressRef.current) { interaction.didLongPressRef.current = false; return; } onPress(); }, [interaction.didLongPressRef, onPress]); const accessibilityState = useMemo(() => ({ selected }), [selected]); return ( {({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => { const isDesktop = !isTouchPlatform; const showScriptsIcon = isDesktop && workspace.hasRunningScripts; const hasRunningService = workspace.scripts.some( (s) => s.lifecycle === "running" && (s.type ?? "service") === "service", ); let scriptIconKind: "service" | "command" | null = null; if (showScriptsIcon) { scriptIconKind = hasRunningService ? "service" : "command"; } const workspaceRowStyle = getProjectWorkspaceRowStyle({ isDragging, selected, isHovered, }); return ( } style={styles.workspaceRowContainer} {...hoverHandlers} > ); }} ); } function WorkspaceRowWithMenu({ workspace, hostBadge, leadingProjectName, leadingProjectIconDataUri, selected, shortcutNumber, showShortcutBadge, onPress, drag, isDragging, dragHandleProps, canCopyBranchName, canPin, onToggleWorkspacePin, reserveIdleStatusIndicatorSpace = true, isCreating = false, }: { workspace: SidebarWorkspaceEntry; hostBadge?: HostBadgeModel | null; leadingProjectName?: string | null; leadingProjectIconDataUri?: string | null; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; canPin: boolean; onToggleWorkspacePin: ToggleSidebarWorkspacePin; reserveIdleStatusIndicatorSpace?: boolean; isCreating?: boolean; }) { const { t } = useTranslation(); const toast = useToast(); const [isHidingWorkspace, setIsHidingWorkspace] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); const isArchiving = workspace.archivingAt !== null || isHidingWorkspace; const redirectAfterArchive = useCallback(() => { redirectIfArchivingActiveWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, activeWorkspaceSelection: selectionForSelectedWorkspace(selected, workspace), }); }, [selected, workspace]); const archiveController = useWorkspaceArchive({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, workspaceKind: workspace.workspaceKind, name: workspace.name, ...toWorktreeArchiveRisk(workspace), onArchiveStarted: redirectAfterArchive, onSetHiding: setIsHidingWorkspace, }); const handleArchive = useCallback(() => { if (isArchiving) { return; } archiveController.archive(); }, [archiveController, isArchiving]); const handleCopyPath = useCallback(() => { let copyTargetDirectory: string; try { copyTargetDirectory = requireWorkspaceDirectory({ workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, }); } catch (error) { toast.error( error instanceof Error ? error.message : t("sidebar.workspace.toasts.workspacePathUnavailable"), ); return; } void Clipboard.setStringAsync(copyTargetDirectory); toast.copied(t("sidebar.workspace.toasts.pathCopied")); }, [t, toast, workspace.workspaceDirectory, workspace.workspaceId]); const handleCopyBranchName = useCallback(() => { if (!workspace.currentBranch) { return; } void Clipboard.setStringAsync(workspace.currentBranch); toast.copied(t("sidebar.workspace.toasts.branchNameCopied")); }, [t, toast, workspace.currentBranch]); const renameMutation = useMutation({ mutationFn: async (title: string) => { const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) { throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); } await client.setWorkspaceTitle(workspace.workspaceId, title.length === 0 ? null : title); }, }); const handleOpenRename = useCallback(() => { setIsRenameOpen(true); }, []); const handleCloseRename = useCallback(() => { setIsRenameOpen(false); }, []); const handleSubmitRename = useCallback( async (value: string) => { await renameMutation.mutateAsync(value.trim()); }, [renameMutation], ); const isPinned = workspace.pinnedAt != null; const handleTogglePin = useCallback(() => { onToggleWorkspacePin(workspace); }, [onToggleWorkspacePin, workspace]); const onTogglePin = canPin ? handleTogglePin : undefined; const archiveShortcutKeys = useShortcutKeys("archive-workspace"); const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); useKeyboardActionHandler({ handlerId: `workspace-archive-${workspace.workspaceKey}`, actions: ["workspace.archive"], enabled: selected && !isArchiving, priority: 0, handle: () => { handleArchive(); return true; }, }); return ( <> ); } interface WorkspaceRowItemProps { workspace: SidebarWorkspacePlacement; workspaceEntry: SidebarWorkspaceEntry | null; hostBadge?: HostBadgeModel | null; leadingProjectName?: string | null; leadingProjectIconDataUri?: string | null; shortcutNumber: number | null; showShortcutBadge: boolean; canCopyBranchName: boolean; canPin: boolean; onToggleWorkspacePin: ToggleSidebarWorkspacePin; reserveIdleStatusIndicatorSpace?: boolean; isCreating?: boolean; selectionEnabled: boolean; activeWorkspaceSelection: ActiveWorkspaceSelection | null; onWorkspacePress?: () => void; drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps; } function WorkspaceRowItem({ workspace, workspaceEntry, hostBadge, leadingProjectName, leadingProjectIconDataUri, shortcutNumber, showShortcutBadge, canCopyBranchName, canPin, onToggleWorkspacePin, reserveIdleStatusIndicatorSpace = true, isCreating = false, selectionEnabled, activeWorkspaceSelection, onWorkspacePress, drag, isDragging = false, dragHandleProps, }: WorkspaceRowItemProps) { const handlePress = useCallback(() => { if (!workspace.serverId) { return; } onWorkspacePress?.(); navigateToWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId }); }, [onWorkspacePress, workspace.serverId, workspace.workspaceId]); return ( ); } function areWorkspaceRowItemPropsEqual( previous: WorkspaceRowItemProps, next: WorkspaceRowItemProps, ): boolean { const previousSelected = isWorkspaceSelected({ selection: previous.activeWorkspaceSelection, serverId: previous.workspace.serverId, workspaceId: previous.workspace.workspaceId, enabled: previous.selectionEnabled, }); const nextSelected = isWorkspaceSelected({ selection: next.activeWorkspaceSelection, serverId: next.workspace.serverId, workspaceId: next.workspace.workspaceId, enabled: next.selectionEnabled, }); return ( previous.workspace === next.workspace && previous.workspaceEntry === next.workspaceEntry && previous.hostBadge === next.hostBadge && previous.leadingProjectName === next.leadingProjectName && previous.leadingProjectIconDataUri === next.leadingProjectIconDataUri && previous.shortcutNumber === next.shortcutNumber && previous.showShortcutBadge === next.showShortcutBadge && previous.canCopyBranchName === next.canCopyBranchName && previous.canPin === next.canPin && previous.onToggleWorkspacePin === next.onToggleWorkspacePin && previous.reserveIdleStatusIndicatorSpace === next.reserveIdleStatusIndicatorSpace && previous.isCreating === next.isCreating && previous.onWorkspacePress === next.onWorkspacePress && previous.drag === next.drag && previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && previousSelected === nextSelected ); } const MemoWorkspaceRowItem = memo(WorkspaceRowItem, areWorkspaceRowItemPropsEqual); function WorkspaceRow({ workspaceEntry, hostBadge, leadingProjectName, leadingProjectIconDataUri, shortcutNumber, showShortcutBadge, onPress, drag, isDragging, dragHandleProps, canCopyBranchName, canPin, onToggleWorkspacePin, reserveIdleStatusIndicatorSpace = true, isCreating = false, selected, }: { workspaceEntry: SidebarWorkspaceEntry | null; hostBadge?: HostBadgeModel | null; leadingProjectName?: string | null; leadingProjectIconDataUri?: string | null; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; canCopyBranchName: boolean; canPin: boolean; onToggleWorkspacePin: ToggleSidebarWorkspacePin; reserveIdleStatusIndicatorSpace?: boolean; isCreating?: boolean; selected: boolean; }) { if (!workspaceEntry) { return null; } return ( ); } function ProjectBlock({ project, workspaceEntriesByKey, collapsed, displayName, iconDataUri, selectionEnabled, showShortcutBadges, shortcutIndexByWorkspaceKey, parentGestureRef, onToggleCollapsed, onWorkspacePress, onWorkspaceReorder, onWorktreeCreated, drag, isDragging, dragHandleProps, useNestable, dragGestureHostPresented, creatingWorkspaceIds, activeWorkspaceSelection, hostBadgeByServerId, supportsMultiplicityByServerId, supportsPinningByServerId, onToggleWorkspacePin, }: { project: SidebarProjectEntry; workspaceEntriesByKey: ReadonlyMap; collapsed: boolean; displayName: string; iconDataUri: string | null; selectionEnabled: boolean; showShortcutBadges: boolean; shortcutIndexByWorkspaceKey: Map; parentGestureRef?: MutableRefObject; onToggleCollapsed: (projectViewKey: string) => void; onWorkspacePress?: () => void; onWorkspaceReorder: (projectViewKey: string, workspaces: SidebarWorkspacePlacement[]) => void; onWorktreeCreated?: (workspaceId: string) => void; drag: () => void; isDragging: boolean; dragHandleProps?: DraggableListDragHandleProps; useNestable: boolean; dragGestureHostPresented?: boolean; creatingWorkspaceIds: ReadonlySet; activeWorkspaceSelection: ActiveWorkspaceSelection | null; hostBadgeByServerId: ReadonlyMap; supportsMultiplicityByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { const { visibleItems: visibleWorkspaces, expanded: workspacesExpanded, canToggle: canToggleWorkspaces, toggleExpanded: toggleWorkspacesExpanded, } = useLimitedSidebarGroup(project.workspaces); const rowModel = useMemo( () => buildSidebarProjectRowModel({ project, collapsed, supportsMultiplicityByServerId, }), [collapsed, project, supportsMultiplicityByServerId], ); // Collapsed rows hide their workspace rows, so the project row carries the most urgent // status among them; expanded rows leave the signal to the child rows themselves. const aggregateStatusBucket = useSidebarProjectStatusBucket({ workspaces: project.workspaces, enabled: collapsed, }); const active = isProjectSelectedByRoute({ selection: activeWorkspaceSelection, project, enabled: selectionEnabled, }); const renderWorkspaceRow = useCallback( ( item: SidebarWorkspacePlacement, input?: { drag?: () => void; isDragging?: boolean; dragHandleProps?: DraggableListDragHandleProps; }, ) => { return ( ); }, [ project.projectKind, onToggleWorkspacePin, supportsPinningByServerId, activeWorkspaceSelection, creatingWorkspaceIds, hostBadgeByServerId, onWorkspacePress, selectionEnabled, shortcutIndexByWorkspaceKey, showShortcutBadges, workspaceEntriesByKey, ], ); const renderWorkspace = useCallback( ({ item, drag: workspaceDrag, isActive, dragHandleProps: workspaceDragHandleProps, }: DraggableRenderItemInfo) => { return renderWorkspaceRow(item, { drag: workspaceDrag, isDragging: isActive, dragHandleProps: workspaceDragHandleProps, }); }, [renderWorkspaceRow], ); const handleWorkspaceDragEnd = useCallback( (workspaces: SidebarWorkspacePlacement[]) => { onWorkspaceReorder(project.viewKey, workspaces); }, [onWorkspaceReorder, project.viewKey], ); const toast = useToast(); const { t } = useTranslation(); const [isRemovingProject, setIsRemovingProject] = useState(false); const handleRemoveProject = useCallback(() => { if (isRemovingProject) { return; } void (async () => { const confirmed = await confirmDialog({ title: t("sidebar.project.confirmations.removeTitle"), message: t("sidebar.project.confirmations.removeMessage", { projectName: displayName }), confirmLabel: t("sidebar.project.confirmations.removeConfirm"), cancelLabel: t("sidebar.project.confirmations.cancel"), destructive: true, }); if (!confirmed) { return; } setIsRemovingProject(true); const readiness = getCurrentProjectRemoveReadiness({ hosts: project.hosts, }); if (readiness.kind === "needs_host_update") { toast.error(t("sidebar.project.toasts.updateHostToRemove")); setIsRemovingProject(false); return; } void removeProjectFromHosts({ targets: readiness.targets, getClient: (serverId) => getHostRuntimeStore().getClient(serverId), }) .then((outcome) => { if (outcome.kind === "host_disconnected") { toast.error(t("sidebar.project.toasts.hostDisconnected")); return null; } if (outcome.kind === "failed") { toast.error(t("sidebar.project.toasts.removeFailed")); } return null; }) .catch((error) => { toast.error( error instanceof Error ? error.message : t("sidebar.project.toasts.removeFailed"), ); }) .finally(() => { setIsRemovingProject(false); }); })(); }, [isRemovingProject, displayName, t, toast, project.hosts]); const handleToggleCollapsed = useCallback(() => { onToggleCollapsed(project.viewKey); }, [onToggleCollapsed, project.viewKey]); let projectChildren = null; if (!collapsed) { if (project.workspaces.length > 0) { projectChildren = ( <> {canToggleWorkspaces ? ( ) : null} ); } else if (rowModel.trailingAction.kind === "new_workspace") { projectChildren = ( ); } } return ( {projectChildren} ); } type ProjectBlockProps = Parameters[0]; // oxlint-disable-next-line complexity function areProjectBlockPropsEqual(previous: ProjectBlockProps, next: ProjectBlockProps): boolean { return ( previous.project === next.project && previous.workspaceEntriesByKey === next.workspaceEntriesByKey && previous.collapsed === next.collapsed && previous.displayName === next.displayName && previous.iconDataUri === next.iconDataUri && previous.selectionEnabled === next.selectionEnabled && previous.showShortcutBadges === next.showShortcutBadges && previous.shortcutIndexByWorkspaceKey === next.shortcutIndexByWorkspaceKey && previous.hostBadgeByServerId === next.hostBadgeByServerId && previous.supportsMultiplicityByServerId === next.supportsMultiplicityByServerId && previous.supportsPinningByServerId === next.supportsPinningByServerId && previous.onToggleWorkspacePin === next.onToggleWorkspacePin && previous.parentGestureRef === next.parentGestureRef && previous.onToggleCollapsed === next.onToggleCollapsed && previous.onWorkspacePress === next.onWorkspacePress && previous.onWorkspaceReorder === next.onWorkspaceReorder && previous.onWorktreeCreated === next.onWorktreeCreated && previous.drag === next.drag && previous.isDragging === next.isDragging && previous.dragHandleProps === next.dragHandleProps && previous.useNestable === next.useNestable && previous.dragGestureHostPresented === next.dragGestureHostPresented && previous.creatingWorkspaceIds === next.creatingWorkspaceIds && areProjectBlockSelectionsEqual(previous, next) ); } function areProjectBlockSelectionsEqual( previous: ProjectBlockProps, next: ProjectBlockProps, ): boolean { const previousActive = isProjectSelectedByRoute({ selection: previous.activeWorkspaceSelection, project: previous.project, enabled: previous.selectionEnabled, }); const nextActive = isProjectSelectedByRoute({ selection: next.activeWorkspaceSelection, project: next.project, enabled: next.selectionEnabled, }); if (previousActive !== nextActive) { return false; } if (!previousActive) { return true; } return ( activeWorkspaceSelectionKey(previous.activeWorkspaceSelection) === activeWorkspaceSelectionKey(next.activeWorkspaceSelection) ); } const MemoProjectBlock = memo(ProjectBlock, areProjectBlockPropsEqual); export function SidebarWorkspaceList({ statusGroups, pinnedGroups, projects, workspaceEntriesByKey, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, groupMode, isRefreshing: _isRefreshing = false, onRefresh: _onRefresh, onWorkspacePress, onAddProject, listFooterComponent, listHeaderComponent, parentGestureRef, dragGestureHostPresented, }: SidebarWorkspaceListProps) { const pathname = usePathname(); const hosts = useHosts(); const localServerId = useLocalDaemonServerId(); const localDaemon = useLocalDaemonServerIdState(); const hostBadgeByServerId = useMemo( () => selectHostBadges({ hosts, localServerId, localHostResolutionPending: localDaemon.status !== "resolved", enabled: shouldShowSidebarHostLabels(projects), }), [hosts, localDaemon.status, localServerId, projects], ); const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]); const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity"); const supportsPinningByServerId = useHostFeatureMap(serverIds, "workspacePinning"); const onToggleWorkspacePin = useSidebarWorkspacePinController(); // Status mode drops the project grouping, so its rows carry their own project // icon. Project mode fetches the same icons inside ProjectModeList for its // project headers, so only the active mode requests them. const statusProjectIconTargets = useMemo( () => (groupMode === "status" ? resolveSidebarProjectIconTargets(projects) : []), [groupMode, projects], ); const statusProjectIconByProjectViewKey = useProjectIcons({ projects: statusProjectIconTargets, }); const content = groupMode === "status" ? ( ) : ( ); return content; } function SidebarStatusModeWrapper({ statusGroups, pinnedGroups, workspaceEntriesByKey, projectIconByProjectViewKey, shortcutIndexByWorkspaceKey: _projectShortcutIndex, onWorkspacePress, hostBadgeByServerId, supportsPinningByServerId, onToggleWorkspacePin, listHeaderComponent, }: { statusGroups: StatusGroup[]; pinnedGroups: PinnedSidebarGroups; workspaceEntriesByKey: ReadonlyMap; projectIconByProjectViewKey: ReadonlyMap; shortcutIndexByWorkspaceKey: Map; onWorkspacePress?: () => void; hostBadgeByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; listHeaderComponent?: ReactElement | null; }) { const showShortcutBadges = useShowShortcutBadges(); return ( { const entry = workspaceEntriesByKey.get(workspace.workspaceKey); return entry ? [entry] : []; })} projectIconByProjectViewKey={projectIconByProjectViewKey} shortcutIndexByWorkspaceKey={_projectShortcutIndex} showShortcutBadges={showShortcutBadges} onWorkspacePress={onWorkspacePress} hostBadgeByServerId={hostBadgeByServerId} supportsPinningByServerId={supportsPinningByServerId} onToggleWorkspacePin={onToggleWorkspacePin} listHeaderComponent={listHeaderComponent} /> ); } function ProjectModeList({ projects, pinnedGroups, workspaceEntriesByKey, collapsedProjectKeys, onToggleProjectCollapsed, shortcutIndexByWorkspaceKey, onWorkspacePress, onAddProject, listFooterComponent, listHeaderComponent, parentGestureRef, dragGestureHostPresented, pathname, hostBadgeByServerId, supportsMultiplicityByServerId, supportsPinningByServerId, onToggleWorkspacePin, }: Omit & { pathname: string; hostBadgeByServerId: ReadonlyMap; supportsMultiplicityByServerId: ReadonlyMap; supportsPinningByServerId: ReadonlyMap; onToggleWorkspacePin: ToggleSidebarWorkspacePin; }) { const hasActiveHostFilter = useSidebarViewStore((state) => state.hostFilters.length > 0); const { t } = useTranslation(); const [creatingWorkspaceIds, setCreatingWorkspaceIds] = useState>(() => new Set()); const creatingWorkspaceTimeoutsRef = useRef>>( new Map(), ); const showShortcutBadges = useShowShortcutBadges(); const pinnedCollapsed = useSidebarCollapsedSectionsStore((state) => state.collapsedPinned); const togglePinnedCollapsed = useSidebarCollapsedSectionsStore( (state) => state.togglePinnedCollapsed, ); const getProjectOrder = useSidebarOrderStore((state) => state.getProjectOrder); const setProjectOrder = useSidebarOrderStore((state) => state.setProjectOrder); const getWorkspaceOrder = useSidebarOrderStore((state) => state.getWorkspaceOrder); const setWorkspaceOrder = useSidebarOrderStore((state) => state.setWorkspaceOrder); const isWorkspaceRoute = useMemo( () => Boolean(pathname && parseHostWorkspaceRouteFromPathname(pathname)), [pathname], ); const selectionEnabled = isWorkspaceRoute; const activeWorkspaceSelection = useActiveWorkspaceSelection(); const { pinnedChats, unpinnedProjects } = pinnedGroups; const { visibleItems: visiblePinnedChats, expanded: pinnedChatsExpanded, canToggle: canTogglePinnedChats, toggleExpanded: togglePinnedChatsExpanded, } = useLimitedSidebarGroup(pinnedChats); const projectIconTargets = useMemo(() => resolveSidebarProjectIconTargets(projects), [projects]); const nativeScrollGestureProps = useMemo( () => parentGestureRef ? ({ // NestableScrollContainer forwards props to RNGH ScrollView. Keep // vertical scroll and sidebar close pan simultaneous: vertical // intent scrolls immediately, clear horizontal intent can still // activate close from inside the list. simultaneousHandlers: parentGestureRef, } as object) : undefined, [parentGestureRef], ); const projectIconByProjectViewKey = useProjectIcons({ projects: projectIconTargets, }); 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[]) => { const reorderedProjectKeys = reorderedProjects.map((project) => project.viewKey); const currentProjectOrder = getProjectOrder(); if ( !hasVisibleOrderChanged({ currentOrder: currentProjectOrder, reorderedVisibleKeys: reorderedProjectKeys, }) ) { return; } setProjectOrder( mergeWithRemainder({ currentOrder: currentProjectOrder, reorderedVisibleKeys: reorderedProjectKeys, }), ); }, [getProjectOrder, setProjectOrder], ); const handleWorkspaceReorder = useCallback( (projectViewKey: string, reorderedWorkspaces: SidebarWorkspacePlacement[]) => { const reorderedWorkspaceKeys = reorderedWorkspaces.map((workspace) => workspace.workspaceKey); const currentWorkspaceOrder = getWorkspaceOrder(projectViewKey); if ( !hasVisibleOrderChanged({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, }) ) { return; } setWorkspaceOrder( projectViewKey, mergeWithRemainder({ currentOrder: currentWorkspaceOrder, reorderedVisibleKeys: reorderedWorkspaceKeys, }), ); }, [getWorkspaceOrder, 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 renderProjectBlock = useCallback( ( item: SidebarProjectEntry, dragState: { drag: () => void; isDragging: boolean; dragHandleProps?: DraggableRenderItemInfo["dragHandleProps"]; }, ) => { return ( ); }, [ collapsedProjectKeys, activeWorkspaceSelection, handleWorktreeCreated, handleWorkspaceReorder, hostBadgeByServerId, supportsMultiplicityByServerId, supportsPinningByServerId, onToggleWorkspacePin, onWorkspacePress, onToggleProjectCollapsed, parentGestureRef, dragGestureHostPresented, projectIconByProjectViewKey, selectionEnabled, shortcutIndexByWorkspaceKey, showShortcutBadges, workspaceEntriesByKey, creatingWorkspaceIds, ], ); const renderProject = useCallback( ({ item, drag, isActive, dragHandleProps }: DraggableRenderItemInfo) => renderProjectBlock(item, { drag, isDragging: isActive, dragHandleProps }), [renderProjectBlock], ); const renderPinnedChat = useCallback( (workspace: SidebarWorkspacePlacement) => { return ( ); }, [ activeWorkspaceSelection, creatingWorkspaceIds, hostBadgeByServerId, onWorkspacePress, selectionEnabled, shortcutIndexByWorkspaceKey, showShortcutBadges, supportsPinningByServerId, onToggleWorkspacePin, projectIconByProjectViewKey, workspaceEntriesByKey, ], ); const content = ( <> {pinnedChats.length > 0 ? ( {pinnedCollapsed ? null : ( <> {visiblePinnedChats.map(renderPinnedChat)} {canTogglePinnedChats ? ( ) : null} )} ) : null} {unpinnedProjects.length > 0 || hasActiveHostFilter ? listHeaderComponent : null} {projects.length === 0 ? ( {t("sidebar.project.empty.title")} {t("sidebar.project.empty.description")} ) : ( )} {listFooterComponent} ); return ( {platformIsNative ? ( {content} ) : ( {content} )} ); } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, }, list: { flex: 1, }, listContent: { paddingHorizontal: theme.spacing[2], // Optical inset: aligns the visible Pinned/Workspaces glyph edge with the // Schedules icon across the divider; their layout boxes have different insets. paddingTop: 2, paddingBottom: theme.spacing[4], }, projectListContainer: { width: "100%", }, pinnedSection: { marginBottom: theme.spacing[1], }, projectBlock: { marginBottom: theme.spacing[1], }, workspaceListContainer: {}, newWorkspaceGhostRow: { minHeight: 32, marginLeft: theme.spacing[6], marginRight: theme.spacing[1], paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.md, flexDirection: "row", alignItems: "center", gap: theme.spacing[2], userSelect: "none", }, newWorkspaceGhostRowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, newWorkspaceGhostIconSlot: { width: theme.iconSize.sm, height: theme.iconSize.sm, alignItems: "center", justifyContent: "center", flexShrink: 0, }, newWorkspaceGhostText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, minWidth: 0, flexShrink: 1, }, newWorkspaceGhostTextHovered: { fontSize: theme.fontSize.sm, minWidth: 0, flexShrink: 1, color: theme.colors.foreground, }, emptyContainer: { marginHorizontal: theme.spacing[2], marginTop: theme.spacing[4], paddingTop: theme.spacing[6], paddingBottom: theme.spacing[4], paddingHorizontal: theme.spacing[4], borderRadius: theme.borderRadius.lg, backgroundColor: theme.colors.surface0, alignItems: "center", gap: theme.spacing[3], }, emptyTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, textAlign: "center", }, emptyText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, textAlign: "center", }, projectRow: { position: "relative", minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[1], flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], userSelect: "none", }, projectRowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, projectRowPressed: { backgroundColor: theme.colors.surface2, }, projectRowDragging: { backgroundColor: theme.colors.surface2, borderWidth: 1, borderColor: theme.colors.border, transform: [{ scale: 1.02 }], zIndex: 3, ...theme.shadow.md, }, projectRowLeft: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, }, projectTitleGroup: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flex: 1, minWidth: 0, }, projectTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: "400", minWidth: 0, flexShrink: 1, }, projectActionButton: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], borderRadius: theme.borderRadius.md, flexShrink: 0, }, projectActionButtonHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, projectActionButtonText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, }, projectIconActionButton: { width: 24, height: 24, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", flexShrink: 0, }, projectIconActionButtonHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, projectIconActionButtonHidden: { opacity: 0, }, projectTrailingActions: { flexDirection: "row", alignItems: "center", gap: 2, flexShrink: 0, }, projectKebabButton: { width: 24, height: 24, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", flexShrink: 0, }, projectKebabButtonHidden: { opacity: 0, }, projectKebabButtonHovered: { backgroundColor: theme.colors.surface2, }, projectTrailingControlSlot: { width: 24, height: 24, alignItems: "center", justifyContent: "center", flexShrink: 0, }, projectActionTooltipRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, projectActionTooltipText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, }, projectActionTooltipShortcut: {}, projectShortcutBadgeOverlay: { position: "absolute", top: theme.spacing[2] + 1, right: theme.spacing[2], }, workspaceRow: { minHeight: 36, marginBottom: theme.spacing[1], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], 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", }, 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, }, }));