import { memo, useCallback, useMemo, useState } from "react"; import { usePathname } from "expo-router"; import { useTranslation } from "react-i18next"; import { View, Text, Pressable, ScrollView, type PressableStateCallbackType } from "react-native"; import { NestableScrollContainer } from "react-native-draggable-flatlist"; import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"; import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store"; import { useSidebarWorkspaceEntry, type SidebarStatusWorkspacePlacement, type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { buildStatusGroups, buildStatusShortcutIndex, type StatusGroup, } from "@/hooks/sidebar-status-view-model"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { StyleSheet } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import { withUnistyles } from "react-native-unistyles"; import { ChevronDown, ChevronRight, CircleAlert, CircleCheck, CircleDot, CircleX, MoreVertical, Copy, Archive, Pencil, } from "lucide-react-native"; import { DiffStat } from "@/components/diff-stat"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/contexts/toast-context"; import { useMutation } from "@tanstack/react-query"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "@/utils/workspace-directory"; import { redirectIfArchivingActiveWorkspace } from "@/utils/sidebar-workspace-archive-redirect"; import { useWorkspaceArchive } from "@/workspace/use-workspace-archive"; import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { toWorktreeArchiveRisk } from "@/git/worktree-archive-warning"; import * as Clipboard from "expo-clipboard"; 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 { SidebarWorkspaceRowFrame, SidebarWorkspaceRowContent, SidebarWorkspaceTrailingActionBase, SidebarWorkspaceTrailingActionOverlay, SidebarWorkspaceTrailingActionSlot, } from "@/components/sidebar/sidebar-workspace-row-content"; import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store"; // Themed icon wrappers const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] }); const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedCircleCheck = withUnistyles(CircleCheck); const ThemedCircleDot = withUnistyles(CircleDot); const ThemedCircleX = withUnistyles(CircleX); const ThemedMoreVertical = withUnistyles(MoreVertical); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); const ThemedPencil = withUnistyles(Pencil); const copyLeadingIcon = ; const markAsReadLeadingIcon = ( ); const archiveLeadingIcon = ; const renameLeadingIcon = ; interface StatusWorkspaceListProps { workspaces: SidebarStatusWorkspacePlacement[]; projectNamesByKey: Map; shortcutIndexByWorkspaceKey: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; } export function SidebarStatusWorkspaceList({ workspaces, projectNamesByKey, shortcutIndexByWorkspaceKey: _projectShortcutIndex, showShortcutBadges, onWorkspacePress, hostLabelByServerId, showHostLabels, }: StatusWorkspaceListProps) { const groups = useMemo( () => buildStatusGroups(workspaces, projectNamesByKey), [workspaces, projectNamesByKey], ); const collapsedStatusGroupKeys = useSidebarCollapsedSectionsStore( (state) => state.collapsedStatusGroupKeys, ); const statusShortcutIndex = useMemo( () => showShortcutBadges ? buildStatusShortcutIndex( groups.filter((group) => !collapsedStatusGroupKeys.has(group.bucket)), ) : new Map(), [collapsedStatusGroupKeys, groups, showShortcutBadges], ); return ( {platformIsNative ? ( ) : ( )} ); } function StatusGroupList({ groups, collapsedStatusGroupKeys, projectNamesByKey, shortcutIndex, showShortcutBadges, onWorkspacePress, hostLabelByServerId, showHostLabels, }: { groups: StatusGroup[]; collapsedStatusGroupKeys: ReadonlySet; projectNamesByKey: Map; shortcutIndex: Map; showShortcutBadges: boolean; onWorkspacePress?: () => void; hostLabelByServerId: ReadonlyMap; showHostLabels: boolean; }) { return ( <> {groups.map((group) => ( {!collapsedStatusGroupKeys.has(group.bucket) ? ( {group.rows.map((workspace) => ( ))} ) : null} ))} ); } // Status mode breaks the project grouping, so the row needs the project name to stay // legible; the host is appended after a middle dot once labels are active. function buildStatusRowSubtitle({ projectName, hostLabel, }: { projectName: string; hostLabel: string | null; }): string { if (!hostLabel) { return projectName; } return projectName ? `${projectName} ยท ${hostLabel}` : hostLabel; } function StatusGroupHeader({ group, collapsed }: { group: StatusGroup; collapsed: boolean }) { const [isHovered, setIsHovered] = useState(false); const toggleStatusGroupCollapsed = useSidebarCollapsedSectionsStore( (state) => state.toggleStatusGroupCollapsed, ); const handlePress = useCallback(() => { toggleStatusGroupCollapsed(group.bucket); }, [group.bucket, toggleStatusGroupCollapsed]); const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); const rowStyle = useCallback( ({ pressed }: PressableStateCallbackType) => [ styles.statusGroupRow, isHovered && styles.statusGroupRowHovered, pressed && styles.statusGroupRowPressed, ], [isHovered], ); const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); return ( {group.label} ); } function StatusGroupLeadingVisual({ bucket, collapsed, showChevron, }: { bucket: StatusGroup["bucket"]; collapsed: boolean; showChevron: boolean; }) { if (!showChevron) { return ; } if (collapsed) { return ; } return ; } function StatusGroupIcon({ bucket }: { bucket: StatusGroup["bucket"] }) { switch (bucket) { case "needs_input": return ; case "failed": return ; case "attention": return ; case "running": return ; case "done": return ; } } const StatusWorkspaceRow = memo(function StatusWorkspaceRow({ workspace, subtitle, shortcutNumber, showShortcutBadge, onWorkspacePress, }: { workspace: SidebarStatusWorkspacePlacement; subtitle: string; shortcutNumber: number | null; showShortcutBadge: boolean; onWorkspacePress?: () => void; }) { const workspaceEntry = useSidebarWorkspaceEntry(workspace.serverId, workspace.workspaceId); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const currentPathname = usePathname(); const selected = activeWorkspaceSelection?.serverId === workspace.serverId && activeWorkspaceSelection?.workspaceId === workspace.workspaceId; const handlePress = useCallback(() => { if (!workspace.serverId) return; onWorkspacePress?.(); navigateToWorkspace(workspace.serverId, workspace.workspaceId, { currentPathname }); }, [currentPathname, onWorkspacePress, workspace.serverId, workspace.workspaceId]); if (!workspaceEntry) return null; return ( ); }); function StatusWorkspaceRowWithMenu({ workspace, subtitle, selected, shortcutNumber, showShortcutBadge, onPress, }: { workspace: SidebarWorkspaceEntry; subtitle: string; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; }) { const { t } = useTranslation(); const toast = useToast(); const [isHidingWorkspace, setIsHidingWorkspace] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); const workspaceDirectory = resolveWorkspaceDirectory({ workspaceDirectory: workspace.workspaceDirectory, }); const worktreeArchiveStatus = useCheckoutGitActionsStore((state) => workspaceDirectory ? state.getStatus({ serverId: workspace.serverId, cwd: workspaceDirectory, actionId: "archive-worktree", }) : "idle", ); const isWorktree = workspace.workspaceKind === "worktree"; const isArchiving = isWorktree ? workspace.archivingAt !== null : isHidingWorkspace; const redirectAfterArchive = useCallback(() => { redirectIfArchivingActiveWorkspace({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, activeWorkspaceSelection: selected ? { serverId: workspace.serverId, workspaceId: workspace.workspaceId } : null, }); }, [selected, workspace]); const archiveController = useWorkspaceArchive({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, workspaceDirectory: workspace.workspaceDirectory, 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 : "Workspace path not available"); return; } void Clipboard.setStringAsync(copyTargetDirectory); toast.copied("Path copied"); }, [toast, workspace.workspaceDirectory, workspace.workspaceId]); const handleCopyBranchName = useCallback(() => { void Clipboard.setStringAsync(workspace.name); toast.copied("Branch name copied"); }, [toast, workspace.name]); const renameMutation = useMutation({ mutationFn: async (title: string) => { const client = getHostRuntimeStore().getClient(workspace.serverId); if (!client) throw new Error(t("workspace.terminal.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 archiveShortcutKeys = useShortcutKeys("archive-worktree"); const { hasClearableAttention, clearAttention } = useClearWorkspaceAttention({ serverId: workspace.serverId, workspaceId: workspace.workspaceId, }); const handleMarkAsRead = useCallback(() => { void clearAttention().catch((error) => { toast.error(error instanceof Error ? error.message : "Failed to mark workspace as read"); }); }, [clearAttention, toast]); useKeyboardActionHandler({ handlerId: `worktree-archive-${workspace.workspaceKey}`, actions: ["worktree.archive"], enabled: selected && !isArchiving, priority: 0, handle: () => { handleArchive(); return true; }, }); let computedArchiveStatus: "idle" | "pending" | "success" = "idle"; if (isWorktree) { computedArchiveStatus = worktreeArchiveStatus; } else if (isHidingWorkspace) { computedArchiveStatus = "pending"; } return ( <> ); } function StatusWorkspaceRowInner({ workspace, subtitle, selected, shortcutNumber, showShortcutBadge, onPress, isArchiving, archiveLabel, archiveStatus = "idle", archivePendingLabel, onArchive, onCopyBranchName, onCopyPath, onRename, onMarkAsRead, archiveShortcutKeys, }: { workspace: SidebarWorkspaceEntry; subtitle: string; selected: boolean; shortcutNumber: number | null; showShortcutBadge: boolean; onPress: () => void; isArchiving: boolean; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; onRename?: () => void; onMarkAsRead?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; }) { const isTouchPlatform = platformIsNative; 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 accessibilityState = useMemo(() => ({ selected }), [selected]); return ( {({ isHovered, hoverHandlers }) => { const showShortcut = showShortcutBadge && shortcutNumber !== null; const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); const showKebabInSlot = showKebab && !showShortcut; const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat); const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered }); return ( {shouldRenderActionSlot ? ( ) : null} ); }} ); } function StatusWorkspaceActionSlot({ workspace, showBase, showOverlay, onCopyPath, onCopyBranchName, onRename, onMarkAsRead, onArchive, archiveLabel, archiveStatus, archivePendingLabel, archiveShortcutKeys, }: { workspace: SidebarWorkspaceEntry; showBase: boolean; showOverlay: boolean; onCopyPath?: () => void; onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; onArchive?: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; }) { return ( {workspace.diffStat ? ( ) : null} {onArchive ? ( ) : null} ); } function StatusKebabMenu({ workspaceKey, onCopyPath, onCopyBranchName, onRename, onMarkAsRead, onArchive, archiveLabel, archiveStatus, archivePendingLabel, archiveShortcutKeys, }: { workspaceKey: string; onCopyPath?: () => void; onCopyBranchName?: () => void; onRename?: () => void; onMarkAsRead?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; archivePendingLabel?: string; archiveShortcutKeys?: ShortcutKey[][] | null; }) { const archiveTrailing = useMemo( () => (archiveShortcutKeys ? : null), [archiveShortcutKeys], ); return ( {({ hovered }: { hovered?: boolean }) => ( )} {onCopyPath ? ( Copy path ) : null} {onCopyBranchName ? ( Copy branch name ) : null} {onRename ? ( Rename workspace ) : null} {onMarkAsRead ? ( Mark as read ) : null} {archiveLabel ?? "Archive"} ); } function kebabStyle({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) { return [styles.kebabButton, hovered && styles.kebabButtonHovered]; } function getStatusWorkspaceRowStyle({ selected, isHovered, }: { selected: boolean; isHovered: boolean; }) { return [ styles.workspaceRow, selected && styles.sidebarRowSelected, isHovered && styles.workspaceRowHovered, ]; } const styles = StyleSheet.create((theme) => ({ container: { flex: 1, }, list: { flex: 1, }, listContent: { paddingHorizontal: theme.spacing[2], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[4], }, statusGroupBlock: { marginBottom: theme.spacing[1], }, statusWorkspaceListContainer: {}, statusGroupRow: { minHeight: 36, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, marginBottom: theme.spacing[2], flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], userSelect: "none", }, statusGroupRowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, statusGroupRowPressed: { backgroundColor: theme.colors.surface2, }, statusGroupRowLeft: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, }, statusGroupLeadingVisualSlot: { position: "relative", width: theme.iconSize.md, height: theme.iconSize.md, flexShrink: 0, alignItems: "center", justifyContent: "center", }, statusGroupTitleGroup: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flex: 1, minWidth: 0, }, statusGroupTitle: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: "400", minWidth: 0, flexShrink: 1, }, workspaceRowContainer: { position: "relative", }, 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: "flex-start", gap: theme.spacing[1], userSelect: "none", }, workspaceRowHovered: { backgroundColor: theme.colors.surfaceSidebarHover, }, workspaceRowPressed: { backgroundColor: theme.colors.surface2, }, sidebarRowSelected: { backgroundColor: theme.colors.surfaceSidebarHover, }, kebabButton: { padding: 2, borderRadius: 4, marginLeft: 2, }, kebabButtonHovered: { backgroundColor: theme.colors.surface2, }, }));