import { memo, useCallback, useMemo, useState, type ReactNode } from "react"; import { ActivityIndicator, Pressable, Text, View, type GestureResponderEvent, type ViewStyle, } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { CircleAlert, ExternalLink, Folder, FolderGit2, GitPullRequest, Globe, Monitor, SquareTerminal, } from "lucide-react-native"; import { GitHubIcon } from "@/components/icons/github-icon"; import { WorkspaceHoverCard } from "@/components/workspace-hover-card"; import { SyncedLoader } from "@/components/synced-loader"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { Theme } from "@/styles/theme"; import type { PrHint } from "@/git/use-pr-status-query"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { isEmphasizedStatusDotBucket } from "@/utils/status-dot-color"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { openExternalUrl } from "@/utils/open-external-url"; const WORKSPACE_STATUS_DOT_WIDTH = 14; const DEFAULT_STATUS_DOT_SIZE = 7; const EMPHASIZED_STATUS_DOT_SIZE = 9; const DEFAULT_STATUS_DOT_OFFSET = 0; const EMPHASIZED_STATUS_DOT_OFFSET = -1; const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] }); const syncedLoaderColorMapping = (theme: Theme) => ({ color: theme.colorScheme === "light" ? theme.colors.palette.amber[700] : theme.colors.palette.amber[500], }); const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] }); const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] }); const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] }); const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] }); const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedSyncedLoader = withUnistyles(SyncedLoader); const ThemedMonitor = withUnistyles(Monitor); const ThemedFolder = withUnistyles(Folder); const ThemedFolderGit2 = withUnistyles(FolderGit2); const ThemedGlobe = withUnistyles(Globe); const ThemedSquareTerminal = withUnistyles(SquareTerminal); type SidebarWorkspaceScriptIconKind = "service" | "command"; export function SidebarWorkspaceRowFrame({ workspace, isDragging = false, children, }: { workspace: SidebarWorkspaceEntry; isDragging?: boolean; children: (input: { isHovered: boolean; hoverHandlers: { onPointerEnter: () => void; onPointerLeave: () => void }; }) => ReactNode; }) { const [isHovered, setIsHovered] = useState(false); const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); const hoverHandlers = useMemo( () => ({ onPointerEnter: handlePointerEnter, onPointerLeave: handlePointerLeave }), [handlePointerEnter, handlePointerLeave], ); return ( {children({ isHovered, hoverHandlers })} ); } export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowContent({ workspace, subtitle, scriptIconKind = null, isHovered, isLoading, isCreating = false, shortcutNumber = null, showShortcutBadge = false, children, }: { workspace: SidebarWorkspaceEntry; subtitle?: string | null; scriptIconKind?: SidebarWorkspaceScriptIconKind | null; isHovered: boolean; isLoading: boolean; isCreating?: boolean; shortcutNumber?: number | null; showShortcutBadge?: boolean; children?: ReactNode; }) { const workspaceBranchTextStyle = useMemo( () => [ styles.workspaceBranchText, scriptIconKind ? styles.workspaceBranchTextWithAccessory : styles.workspaceBranchTextFlexible, isHovered && styles.workspaceBranchTextHovered, isCreating && styles.workspaceBranchTextCreating, ], [scriptIconKind, isHovered, isCreating], ); return ( {workspace.name} {scriptIconKind ? : null} {children} {showShortcutBadge && shortcutNumber !== null ? ( ) : null} {subtitle ? ( {subtitle} ) : null} {workspace.prHint ? ( ) : null} ); }); function WorkspaceScriptIcon({ kind }: { kind: SidebarWorkspaceScriptIconKind }) { return ( {kind === "service" ? ( ) : ( )} ); } function WorkspaceStatusIndicator({ bucket, workspaceKind, loading = false, }: { bucket: SidebarWorkspaceEntry["statusBucket"]; workspaceKind: SidebarWorkspaceEntry["workspaceKind"]; loading?: boolean; }) { const shouldShowSyncedLoader = shouldRenderSyncedStatusLoader({ bucket }); if (loading) { return ( ); } if (shouldShowSyncedLoader) { return ( ); } if (bucket === "needs_input") { return ( ); } let KindIcon: typeof ThemedMonitor; if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; else KindIcon = ThemedFolder; const dotColorStyle = getStatusDotColorStyle(bucket); const statusDotSize = isEmphasizedStatusDotBucket(bucket) ? EMPHASIZED_STATUS_DOT_SIZE : DEFAULT_STATUS_DOT_SIZE; const statusDotOffset = statusDotSize === EMPHASIZED_STATUS_DOT_SIZE ? EMPHASIZED_STATUS_DOT_OFFSET : DEFAULT_STATUS_DOT_OFFSET; return ( {dotColorStyle ? ( ) : null} ); } function StatusDotOverlay({ dotColorStyle, size, offset, }: { dotColorStyle: ViewStyle; size: number; offset: number; }) { const overlayStyle = useMemo( () => [ styles.statusDotOverlay, dotColorStyle, { width: size, height: size, right: offset, bottom: offset, }, ], [dotColorStyle, offset, size], ); return ; } function PrBadge({ hint }: { hint: PrHint }) { const [isHovered, setIsHovered] = useState(false); const handlePress = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); void openExternalUrl(hint.url); }, [hint.url], ); const textStyle = useMemo( () => (isHovered ? [prBadgeStyles.text, prBadgeStyles.textHovered] : prBadgeStyles.text), [isHovered], ); const iconUniProps = isHovered ? foregroundColorMapping : getPrIconUniMapping(hint.state); const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []); const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); const pressableStyle = useMemo( () => [prBadgeStyles.badge, isHovered && prBadgeStyles.badgePressed], [isHovered], ); return ( {isHovered ? ( ) : ( )} #{hint.number} ); } function ChecksBadge({ checks }: { checks: PrHint["checks"] }) { if (!checks || checks.length === 0) return null; const failed = checks.filter((check) => check.status === "failure").length; if (failed === 0) return null; return ( {failed} failed ); } function getPrIconUniMapping(state: PrHint["state"]) { switch (state) { case "merged": return purpleColorMapping; case "open": return greenColorMapping; case "closed": return redColorMapping; } } function getStatusDotColorStyle(bucket: SidebarStateBucket) { switch (bucket) { case "needs_input": return styles.statusDotNeedsInput; case "failed": return styles.statusDotFailed; case "running": return styles.statusDotRunning; case "attention": return styles.statusDotAttention; case "done": return null; } } const prBadgeStyles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", alignItems: "center", gap: 2, }, badgePressed: { opacity: 0.82, }, text: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, lineHeight: 14, color: theme.colors.foregroundMuted, }, textHovered: { color: theme.colors.foreground, }, })); const checksBadgeStyles = StyleSheet.create((theme) => ({ badge: { flexDirection: "row", alignItems: "center", gap: 2, }, text: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, lineHeight: 14, color: theme.colors.palette.red[500], }, })); export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({ rowRight: { flexDirection: "row", alignItems: "flex-start", gap: theme.spacing[2], flexShrink: 0, }, 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, }, hidden: { opacity: 0 }, trailingActionSlot: { position: "relative", minWidth: 18, minHeight: 20, flexShrink: 0, alignItems: "flex-end", justifyContent: "flex-start", }, trailingActionOverlay: { position: "absolute", top: 0, right: 0, }, })); export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) { return ( {number} ); } export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) { return {children}; } export function SidebarWorkspaceTrailingActionBase({ visible, children, }: { visible: boolean; children: ReactNode; }) { if (!children) return null; return {children}; } export function SidebarWorkspaceTrailingActionOverlay({ visible, children, }: { visible: boolean; children: ReactNode; }) { if (!visible || !children) return null; return {children}; } const styles = StyleSheet.create((theme) => ({ workspaceRowContent: { position: "relative", }, workspaceRowMain: { flexDirection: "row", alignItems: "flex-start", justifyContent: "space-between", gap: theme.spacing[2], width: "100%", }, workspaceRowLeft: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], flex: 1, minWidth: 0, }, workspaceRowRight: sidebarWorkspaceRowStyles.rowRight, shortcutBadgeOverlay: { position: "absolute", top: 1, right: 0, }, workspaceStatusDot: { position: "relative", width: WORKSPACE_STATUS_DOT_WIDTH, height: 20, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "center", justifyContent: "center", }, statusDotOverlay: { position: "absolute", borderRadius: theme.borderRadius.full, borderWidth: 1, }, workspaceBranchText: { color: theme.colors.foreground, fontSize: theme.fontSize.sm, fontWeight: "400", lineHeight: 20, opacity: 0.76, minWidth: 0, }, workspaceBranchTextFlexible: { flex: 1, }, workspaceBranchTextWithAccessory: { flexShrink: 1, }, workspaceTitleAccessory: { height: 20, flexShrink: 0, alignItems: "center", justifyContent: "center", }, workspaceBranchTextCreating: { opacity: 0.92, }, workspaceBranchTextHovered: { opacity: 1, }, workspaceSubtitle: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 14, marginLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2], }, workspacePrBadgeRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], marginTop: theme.spacing[1], paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2], }, statusDotNeedsInput: { backgroundColor: theme.colors.palette.amber[500], borderColor: theme.colors.surface0, }, statusDotFailed: { backgroundColor: theme.colors.palette.red[500], borderColor: theme.colors.surface0, }, statusDotRunning: { backgroundColor: theme.colors.palette.blue[500], borderColor: theme.colors.surface0, }, statusDotAttention: { backgroundColor: theme.colors.palette.green[500], borderColor: theme.colors.surface0, }, }));