diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index 07fc0e2be..d1cc18f04 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -27,6 +27,7 @@ import { type GestureType } from "react-native-gesture-handler"; import * as Clipboard from "expo-clipboard"; import { Archive, + ArrowUpRight, CircleAlert, ChevronDown, ChevronRight, @@ -112,11 +113,6 @@ 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 GITHUB_PR_STATE_LABELS: Record = { - open: "Open", - merged: "Merged", - closed: "Closed", -}; interface SidebarWorkspaceListProps { projects: SidebarProjectEntry[]; @@ -178,7 +174,7 @@ interface WorkspaceRowInnerProps { archiveShortcutKeys?: ShortcutKey[][] | null; } -function WorkspacePrBadge({ hint }: { hint: PrHint }) { +export function PrBadge({ hint }: { hint: PrHint }) { const { theme } = useUnistyles(); const [isHovered, setIsHovered] = useState(false); const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted; @@ -198,32 +194,45 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) { return ( setIsHovered(true)} onPointerLeave={() => setIsHovered(false)} style={({ pressed }) => [ - styles.workspacePrBadge, - pressed && styles.workspacePrBadgePressed, + prBadgeStyles.badge, + pressed && prBadgeStyles.badgePressed, ]} > - #{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]} + #{hint.number} - {isHovered && } + ); } +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, + }, +})); + function WorkspaceStatusIndicator({ bucket, @@ -1042,8 +1051,8 @@ function WorkspaceRowInner({ {prHint ? ( - - + + ) : null} @@ -2414,23 +2423,9 @@ const styles = StyleSheet.create((theme) => ({ workspacePrBadgeRow: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], + gap: theme.spacing[2], paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + theme.spacing[2], }, - workspacePrBadge: { - alignSelf: "flex-start", - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - }, - workspacePrBadgePressed: { - opacity: 0.82, - }, - workspacePrBadgeText: { - fontSize: theme.fontSize.xs, - fontWeight: theme.fontWeight.normal, - lineHeight: 14, - }, workspaceCreatingText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, diff --git a/packages/app/src/components/workspace-hover-card.tsx b/packages/app/src/components/workspace-hover-card.tsx index eb396058f..83f3cc196 100644 --- a/packages/app/src/components/workspace-hover-card.tsx +++ b/packages/app/src/components/workspace-hover-card.tsx @@ -10,7 +10,7 @@ import { useMutation } from "@tanstack/react-query"; import { Dimensions, Platform, Text, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { Check, ExternalLink, GitPullRequest, LoaderCircle, Minus, Play, X } from "lucide-react-native"; +import { Check, ExternalLink, LoaderCircle, Minus, Play, X } from "lucide-react-native"; import { Pressable } from "react-native"; import { Portal } from "@gorhom/portal"; import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; @@ -19,6 +19,7 @@ import type { PrHint } from "@/hooks/use-checkout-pr-status-query"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; import { openExternalUrl } from "@/utils/open-external-url"; +import { PrBadge } from "@/components/sidebar-workspace-list"; interface Rect { x: number; @@ -191,12 +192,6 @@ function WorkspaceHoverCardDesktop({ ); } -const GITHUB_PR_STATE_LABELS: Record = { - open: "Open", - merged: "Merged", - closed: "Closed", -}; - function getServiceHealthColor(input: { health: SidebarWorkspaceEntry["services"][number]["health"]; theme: ReturnType["theme"]; @@ -223,84 +218,93 @@ function getServiceHealthLabel( } +function getCheckStatusColor(input: { + status: string; + theme: ReturnType["theme"]; +}): string { + if (input.status === "success") return input.theme.colors.palette.green[500]; + if (input.status === "failure") return input.theme.colors.palette.red[500]; + if (input.status === "pending") return input.theme.colors.palette.amber[500]; + return input.theme.colors.foregroundMuted; +} + +function getCheckStatusIcon(status: string): typeof Check { + if (status === "success") return Check; + if (status === "failure") return X; + return Minus; +} + export function CheckStatusIndicator({ status, - size = 14, + size = 12, }: { status: string; size?: number; }): ReactElement | null { const { theme } = useUnistyles(); - const iconSize = Math.round(size * 0.6); if (!status || status === "none") return null; - if (status === "pending") { - return ( - - ); - } + const color = getCheckStatusColor({ status, theme }); + const IconComponent = getCheckStatusIcon(status); - if (status === "success") { - return ( - - - - ); - } - - if (status === "failure") { - return ( - - - - ); - } - - // skipped / cancelled / unknown return ( - + ); } +function ChecksSummary({ checks }: { checks: Array<{ status: string }> }): ReactElement { + const { theme } = useUnistyles(); + const counts: Record = {}; + for (const check of checks) { + const bucket = check.status === "success" ? "success" : check.status === "failure" ? "failure" : "pending"; + counts[bucket] = (counts[bucket] ?? 0) + 1; + } + + const buckets: Array<{ status: string; count: number }> = []; + if (counts.failure) buckets.push({ status: "failure", count: counts.failure }); + if (counts.success) buckets.push({ status: "success", count: counts.success }); + if (counts.pending) buckets.push({ status: "pending", count: counts.pending }); + + return ( + <> + {buckets.map((bucket) => { + const color = getCheckStatusColor({ status: bucket.status, theme }); + return ( + + {bucket.count} + + + ); + })} + + ); +} + +const checksSummaryStyles = StyleSheet.create((theme) => ({ + item: { + flexDirection: "row", + alignItems: "center", + gap: 3, + }, + count: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + }, +})); + function WorkspaceHoverCardContent({ workspace, prHint, @@ -406,160 +410,136 @@ function WorkspaceHoverCardContent({ {prHint || workspace.diffStat ? ( - void openExternalUrl(prHint.url) : undefined} - disabled={!prHint} - > - {prHint ? ( - <> - - - #{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]} - - - ) : null} - + {workspace.diffStat ? ( - <> + +{workspace.diffStat.additions} -{workspace.diffStat.deletions} - + ) : null} - - ) : null} - {prHint?.checks && prHint.checks.length > 0 ? ( - <> - - Checks - - {prHint.checks.map((check) => ( - [ - styles.serviceRow, - hovered && check.url && styles.serviceRowHovered, - ]} - onPress={check.url ? () => void openExternalUrl(check.url!) : undefined} - disabled={!check.url} - > - - - {check.name} - - {check.url ? ( - - ) : null} - - ))} + {prHint ? : null} - ) : null} {workspace.services.length > 0 ? ( <> Services - - {workspace.services.map((service) => ( - [ - styles.serviceRow, - hovered && - service.lifecycle === "running" && - service.url && - styles.serviceRowHovered, - ]} - onPress={() => { - if (service.lifecycle === "running" && service.url) { - void openExternalUrl(service.url); - } - }} - > - + {workspace.services.map((service) => { + const isRunning = service.lifecycle === "running"; + const isLinkable = isRunning && !!service.url; + return ( + [ + styles.listRow, + hovered && isLinkable && styles.listRowHovered, ]} - numberOfLines={1} + onPress={isLinkable ? () => void openExternalUrl(service.url!) : undefined} + disabled={!isLinkable} > - {service.serviceName} - - - - {service.lifecycle === "running" ? "Running" : "Stopped"} - - - {getServiceHealthLabel(service.health)} - - {service.lifecycle === "running" && service.url ? ( - - {service.url.replace(/^https?:\/\//, "")} - - ) : ( - - )} - {service.lifecycle === "running" && service.url ? ( - - ) : ( - [ - styles.startServiceButton, - (hovered || pressed) && styles.startServiceButtonHovered, - ]} - disabled={startServiceMutation.isPending} - onPress={(event) => { - event.stopPropagation(); - startServiceMutation.mutate(service.serviceName); - }} - > - {startServiceMutation.isPending && - startServiceMutation.variables === service.serviceName ? ( - ( + <> + - ) : ( - - )} - - )} - - ))} + + {service.serviceName} + + {isRunning && service.url ? ( + + {service.url.replace(/^https?:\/\//, "")} + + ) : ( + + )} + {isRunning ? ( + service.url ? ( + + ) : null + ) : ( + { + event.stopPropagation(); + startServiceMutation.mutate(service.serviceName); + }} + > + {({ hovered: actionHovered }) => + startServiceMutation.isPending && + startServiceMutation.variables === service.serviceName ? ( + + ) : ( + + ) + } + + )} + + )} + + ); + })} ) : null} + {prHint?.checks && prHint.checks.length > 0 ? ( + <> + + [ + styles.checksSummaryRow, + hovered && styles.listRowHovered, + ]} + onPress={() => void openExternalUrl(`${prHint.url}/checks`)} + > + {({ hovered }) => ( + <> + Checks + + + + + + )} + + + ) : null} @@ -609,6 +589,11 @@ const styles = StyleSheet.create((theme) => ({ paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[2], }, + diffStatRow: { + flexDirection: "row", + alignItems: "center", + gap: 4, + }, diffStatAdditions: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, @@ -619,76 +604,10 @@ const styles = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.normal, color: theme.colors.palette.red[500], }, - prBadgeText: { - fontSize: theme.fontSize.xs, - color: theme.colors.foregroundMuted, - }, separator: { height: 1, backgroundColor: theme.colors.border, }, - serviceList: { - paddingTop: theme.spacing[1], - }, - serviceRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[3], - paddingVertical: theme.spacing[2], - minHeight: 32, - }, - serviceRowHovered: { - backgroundColor: theme.colors.surface2, - }, - statusDot: { - width: 8, - height: 8, - borderRadius: 4, - flexShrink: 0, - }, - serviceName: { - fontSize: theme.fontSize.sm, - flexShrink: 0, - }, - serviceMeta: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flexShrink: 0, - }, - serviceLifecycleText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - serviceHealthText: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - }, - serviceUrl: { - color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.xs, - flex: 1, - minWidth: 0, - }, - serviceUrlSpacer: { - flex: 1, - minWidth: 0, - }, - startServiceButton: { - width: 22, - height: 22, - borderRadius: 11, - alignItems: "center", - justifyContent: "center", - backgroundColor: theme.colors.surface2, - }, - startServiceButtonHovered: { - backgroundColor: theme.colors.surface3, - }, - startServiceSpinner: { - transform: [{ rotate: "0deg" }], - }, sectionLabel: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.medium, @@ -697,13 +616,59 @@ const styles = StyleSheet.create((theme) => ({ paddingTop: theme.spacing[2], paddingBottom: theme.spacing[1], }, - checksList: { + sectionList: { paddingBottom: theme.spacing[1], }, - checkName: { + listRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: 6, + minHeight: 28, + }, + listRowHovered: { + backgroundColor: theme.colors.surface2, + }, + listRowLabel: { fontSize: theme.fontSize.sm, + flexShrink: 0, + }, + listRowSecondary: { color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.xs, + flex: 1, + minWidth: 0, + textAlign: "right", + }, + listRowSpacer: { flex: 1, minWidth: 0, }, + statusDot: { + width: 8, + height: 8, + borderRadius: 4, + flexShrink: 0, + }, + checksSummaryRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: 6, + minHeight: 28, + }, + checksSummaryLabel: { + fontSize: theme.fontSize.xs, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + }, + checksSummaryCounts: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + flex: 1, + justifyContent: "flex-end", + }, }));