Extract PrBadge component and refactor workspace hover card

This commit is contained in:
Mohamed Boudra
2026-04-06 22:31:43 +07:00
parent 7b19dd736f
commit 07618f248f
2 changed files with 258 additions and 298 deletions
@@ -27,6 +27,7 @@ import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { import {
Archive, Archive,
ArrowUpRight,
CircleAlert, CircleAlert,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@@ -112,11 +113,6 @@ const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9; const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0; const DEFAULT_STATUS_DOT_OFFSET = 0;
const EMPHASIZED_STATUS_DOT_OFFSET = -1; const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
open: "Open",
merged: "Merged",
closed: "Closed",
};
interface SidebarWorkspaceListProps { interface SidebarWorkspaceListProps {
projects: SidebarProjectEntry[]; projects: SidebarProjectEntry[];
@@ -178,7 +174,7 @@ interface WorkspaceRowInnerProps {
archiveShortcutKeys?: ShortcutKey[][] | null; archiveShortcutKeys?: ShortcutKey[][] | null;
} }
function WorkspacePrBadge({ hint }: { hint: PrHint }) { export function PrBadge({ hint }: { hint: PrHint }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted; const activeColor = isHovered ? theme.colors.foreground : theme.colors.foregroundMuted;
@@ -198,32 +194,45 @@ function WorkspacePrBadge({ hint }: { hint: PrHint }) {
return ( return (
<Pressable <Pressable
accessibilityRole="link" accessibilityRole="link"
accessibilityLabel={`${hint.state} pull request #${hint.number}`} accessibilityLabel={`Pull request #${hint.number}`}
hitSlop={4} hitSlop={4}
onPressIn={handlePressIn} onPressIn={handlePressIn}
onPress={handlePress} onPress={handlePress}
onPointerEnter={() => setIsHovered(true)} onPointerEnter={() => setIsHovered(true)}
onPointerLeave={() => setIsHovered(false)} onPointerLeave={() => setIsHovered(false)}
style={({ pressed }) => [ style={({ pressed }) => [
styles.workspacePrBadge, prBadgeStyles.badge,
pressed && styles.workspacePrBadgePressed, pressed && prBadgeStyles.badgePressed,
]} ]}
> >
<GitPullRequest size={12} color={activeColor} /> <GitPullRequest size={12} color={activeColor} />
<Text <Text
style={[ style={[prBadgeStyles.text, { color: activeColor }]}
styles.workspacePrBadgeText,
{ color: activeColor },
]}
numberOfLines={1} numberOfLines={1}
> >
#{hint.number} · {GITHUB_PR_STATE_LABELS[hint.state]} #{hint.number}
</Text> </Text>
{isHovered && <ExternalLink size={10} color={activeColor} />} <ArrowUpRight size={10} color={activeColor} style={{ opacity: isHovered ? 1 : 0 }} />
</Pressable> </Pressable>
); );
} }
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({ function WorkspaceStatusIndicator({
bucket, bucket,
@@ -1042,8 +1051,8 @@ function WorkspaceRowInner({
</View> </View>
{prHint ? ( {prHint ? (
<View style={styles.workspacePrBadgeRow}> <View style={styles.workspacePrBadgeRow}>
<WorkspacePrBadge hint={prHint} /> <CheckStatusIndicator status={prHint.checksStatus ?? "none"} size={12} />
<CheckStatusIndicator status={prHint.checksStatus ?? "none"} size={11} /> <PrBadge hint={prHint} />
</View> </View>
) : null} ) : null}
</Pressable> </Pressable>
@@ -2414,23 +2423,9 @@ const styles = StyleSheet.create((theme) => ({
workspacePrBadgeRow: { workspacePrBadgeRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[2],
paddingLeft: WORKSPACE_STATUS_DOT_WIDTH + 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: { workspaceCreatingText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
@@ -10,7 +10,7 @@ import { useMutation } from "@tanstack/react-query";
import { Dimensions, Platform, Text, View } from "react-native"; import { Dimensions, Platform, Text, View } from "react-native";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; 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 { Pressable } from "react-native";
import { Portal } from "@gorhom/portal"; import { Portal } from "@gorhom/portal";
import { useBottomSheetModalInternal } from "@gorhom/bottom-sheet"; 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 { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { openExternalUrl } from "@/utils/open-external-url"; import { openExternalUrl } from "@/utils/open-external-url";
import { PrBadge } from "@/components/sidebar-workspace-list";
interface Rect { interface Rect {
x: number; x: number;
@@ -191,12 +192,6 @@ function WorkspaceHoverCardDesktop({
); );
} }
const GITHUB_PR_STATE_LABELS: Record<PrHint["state"], string> = {
open: "Open",
merged: "Merged",
closed: "Closed",
};
function getServiceHealthColor(input: { function getServiceHealthColor(input: {
health: SidebarWorkspaceEntry["services"][number]["health"]; health: SidebarWorkspaceEntry["services"][number]["health"];
theme: ReturnType<typeof useUnistyles>["theme"]; theme: ReturnType<typeof useUnistyles>["theme"];
@@ -223,84 +218,93 @@ function getServiceHealthLabel(
} }
function getCheckStatusColor(input: {
status: string;
theme: ReturnType<typeof useUnistyles>["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({ export function CheckStatusIndicator({
status, status,
size = 14, size = 12,
}: { }: {
status: string; status: string;
size?: number; size?: number;
}): ReactElement | null { }): ReactElement | null {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const iconSize = Math.round(size * 0.6);
if (!status || status === "none") return null; if (!status || status === "none") return null;
if (status === "pending") { const color = getCheckStatusColor({ status, theme });
return ( const IconComponent = getCheckStatusIcon(status);
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
borderWidth: 2,
borderColor: theme.colors.palette.amber[500],
backgroundColor: "transparent",
}}
/>
);
}
if (status === "success") {
return (
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: "rgba(34,197,94,0.15)",
alignItems: "center",
justifyContent: "center",
}}
>
<Check size={iconSize} color={theme.colors.palette.green[500]} strokeWidth={3} />
</View>
);
}
if (status === "failure") {
return (
<View
style={{
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: "rgba(239,68,68,0.15)",
alignItems: "center",
justifyContent: "center",
}}
>
<X size={iconSize} color={theme.colors.palette.red[500]} strokeWidth={3} />
</View>
);
}
// skipped / cancelled / unknown
return ( return (
<View <View
style={{ style={{
width: size, width: size,
height: size, height: size,
borderRadius: size / 2, borderRadius: size / 2,
backgroundColor: "rgba(128,128,128,0.15)", borderWidth: 1,
borderColor: color,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
}} }}
> >
<Minus size={iconSize} color={theme.colors.foregroundMuted} strokeWidth={3} /> <IconComponent size={Math.round(size * 0.5)} color={color} strokeWidth={3} />
</View> </View>
); );
} }
function ChecksSummary({ checks }: { checks: Array<{ status: string }> }): ReactElement {
const { theme } = useUnistyles();
const counts: Record<string, number> = {};
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 (
<View key={bucket.status} style={checksSummaryStyles.item}>
<Text style={[checksSummaryStyles.count, { color }]}>{bucket.count}</Text>
<CheckStatusIndicator status={bucket.status} size={12} />
</View>
);
})}
</>
);
}
const checksSummaryStyles = StyleSheet.create((theme) => ({
item: {
flexDirection: "row",
alignItems: "center",
gap: 3,
},
count: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
},
}));
function WorkspaceHoverCardContent({ function WorkspaceHoverCardContent({
workspace, workspace,
prHint, prHint,
@@ -406,160 +410,136 @@ function WorkspaceHoverCardContent({
</Text> </Text>
</View> </View>
{prHint || workspace.diffStat ? ( {prHint || workspace.diffStat ? (
<Pressable <View style={styles.cardMetaRow}>
style={styles.cardMetaRow}
onPress={prHint ? () => void openExternalUrl(prHint.url) : undefined}
disabled={!prHint}
>
{prHint ? (
<>
<GitPullRequest size={12} color={theme.colors.foregroundMuted} />
<Text style={styles.prBadgeText} numberOfLines={1}>
#{prHint.number} · {GITHUB_PR_STATE_LABELS[prHint.state]}
</Text>
</>
) : null}
{workspace.diffStat ? ( {workspace.diffStat ? (
<> <View style={styles.diffStatRow}>
<Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text> <Text style={styles.diffStatAdditions}>+{workspace.diffStat.additions}</Text>
<Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text> <Text style={styles.diffStatDeletions}>-{workspace.diffStat.deletions}</Text>
</> </View>
) : null} ) : null}
</Pressable> {prHint ? <PrBadge hint={prHint} /> : null}
) : null}
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<Text style={styles.sectionLabel}>Checks</Text>
<View style={styles.checksList}>
{prHint.checks.map((check) => (
<Pressable
key={check.name}
style={({ hovered }) => [
styles.serviceRow,
hovered && check.url && styles.serviceRowHovered,
]}
onPress={check.url ? () => void openExternalUrl(check.url!) : undefined}
disabled={!check.url}
>
<CheckStatusIndicator status={check.status} size={14} />
<Text
style={styles.checkName}
numberOfLines={1}
>
{check.name}
</Text>
{check.url ? (
<ExternalLink size={11} color={theme.colors.foregroundMuted} />
) : null}
</Pressable>
))}
</View> </View>
</>
) : null} ) : null}
{workspace.services.length > 0 ? ( {workspace.services.length > 0 ? (
<> <>
<View style={styles.separator} /> <View style={styles.separator} />
<Text style={styles.sectionLabel}>Services</Text> <Text style={styles.sectionLabel}>Services</Text>
<View style={styles.serviceList} testID="hover-card-service-list"> <View style={styles.sectionList} testID="hover-card-service-list">
{workspace.services.map((service) => ( {workspace.services.map((service) => {
<Pressable const isRunning = service.lifecycle === "running";
key={service.hostname} const isLinkable = isRunning && !!service.url;
accessibilityRole={service.lifecycle === "running" && service.url ? "link" : undefined} return (
accessibilityLabel={`${service.serviceName} service`} <Pressable
testID={`hover-card-service-${service.serviceName}`} key={service.hostname}
style={({ hovered }) => [ accessibilityRole={isLinkable ? "link" : undefined}
styles.serviceRow, accessibilityLabel={`${service.serviceName} service — ${isRunning ? getServiceHealthLabel(service.health) : "Stopped"}`}
hovered && testID={`hover-card-service-${service.serviceName}`}
service.lifecycle === "running" && style={({ hovered }) => [
service.url && styles.listRow,
styles.serviceRowHovered, hovered && isLinkable && styles.listRowHovered,
]}
onPress={() => {
if (service.lifecycle === "running" && service.url) {
void openExternalUrl(service.url);
}
}}
>
<Text
style={[
styles.serviceName,
{
color:
service.lifecycle === "running"
? theme.colors.foreground
: theme.colors.foregroundMuted,
},
]} ]}
numberOfLines={1} onPress={isLinkable ? () => void openExternalUrl(service.url!) : undefined}
disabled={!isLinkable}
> >
{service.serviceName} {({ hovered }) => (
</Text> <>
<View style={styles.serviceMeta}> <View
<Text testID={`hover-card-service-health-${service.serviceName}`}
testID={`hover-card-service-status-${service.serviceName}`} style={[
accessibilityLabel={service.lifecycle === "running" ? "Running" : "Stopped"} styles.statusDot,
style={styles.serviceLifecycleText} {
> backgroundColor: isRunning
{service.lifecycle === "running" ? "Running" : "Stopped"} ? getServiceHealthColor({ health: service.health, theme })
</Text> : theme.colors.foregroundMuted,
<View },
testID={`hover-card-service-health-${service.serviceName}`} ]}
accessibilityLabel={getServiceHealthLabel(service.health)}
style={[
styles.statusDot,
{
backgroundColor: getServiceHealthColor({
health: service.health,
theme,
}),
},
]}
/>
<Text style={styles.serviceHealthText}>{getServiceHealthLabel(service.health)}</Text>
</View>
{service.lifecycle === "running" && service.url ? (
<Text style={styles.serviceUrl} numberOfLines={1}>
{service.url.replace(/^https?:\/\//, "")}
</Text>
) : (
<View style={styles.serviceUrlSpacer} />
)}
{service.lifecycle === "running" && service.url ? (
<ExternalLink size={11} color={theme.colors.foregroundMuted} />
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Start ${service.serviceName} service`}
testID={`hover-card-service-start-${service.serviceName}`}
style={({ hovered, pressed }) => [
styles.startServiceButton,
(hovered || pressed) && styles.startServiceButtonHovered,
]}
disabled={startServiceMutation.isPending}
onPress={(event) => {
event.stopPropagation();
startServiceMutation.mutate(service.serviceName);
}}
>
{startServiceMutation.isPending &&
startServiceMutation.variables === service.serviceName ? (
<LoaderCircle
size={12}
color={theme.colors.foregroundMuted}
style={styles.startServiceSpinner}
/> />
) : ( <Text
<Play size={12} color={theme.colors.foregroundMuted} fill="transparent" /> style={[
)} styles.listRowLabel,
</Pressable> {
)} color: isRunning
</Pressable> ? theme.colors.foreground
))} : theme.colors.foregroundMuted,
},
]}
numberOfLines={1}
>
{service.serviceName}
</Text>
{isRunning && service.url ? (
<Text style={styles.listRowSecondary} numberOfLines={1}>
{service.url.replace(/^https?:\/\//, "")}
</Text>
) : (
<View style={styles.listRowSpacer} />
)}
{isRunning ? (
service.url ? (
<ExternalLink
size={12}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
) : null
) : (
<Pressable
accessibilityRole="button"
accessibilityLabel={`Start ${service.serviceName} service`}
testID={`hover-card-service-start-${service.serviceName}`}
hitSlop={4}
disabled={startServiceMutation.isPending}
onPress={(event) => {
event.stopPropagation();
startServiceMutation.mutate(service.serviceName);
}}
>
{({ hovered: actionHovered }) =>
startServiceMutation.isPending &&
startServiceMutation.variables === service.serviceName ? (
<LoaderCircle size={12} color={theme.colors.foregroundMuted} />
) : (
<Play
size={12}
color={actionHovered ? theme.colors.foreground : theme.colors.foregroundMuted}
fill="transparent"
/>
)
}
</Pressable>
)}
</>
)}
</Pressable>
);
})}
</View> </View>
</> </>
) : null} ) : null}
{prHint?.checks && prHint.checks.length > 0 ? (
<>
<View style={styles.separator} />
<Pressable
style={({ hovered }) => [
styles.checksSummaryRow,
hovered && styles.listRowHovered,
]}
onPress={() => void openExternalUrl(`${prHint.url}/checks`)}
>
{({ hovered }) => (
<>
<Text style={styles.checksSummaryLabel}>Checks</Text>
<View style={styles.checksSummaryCounts}>
<ChecksSummary checks={prHint.checks!} />
</View>
<ExternalLink
size={12}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
</>
)}
</Pressable>
</>
) : null}
</Animated.View> </Animated.View>
</View> </View>
</Portal> </Portal>
@@ -609,6 +589,11 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2], paddingBottom: theme.spacing[2],
}, },
diffStatRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
diffStatAdditions: { diffStatAdditions: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
@@ -619,76 +604,10 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.palette.red[500], color: theme.colors.palette.red[500],
}, },
prBadgeText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
separator: { separator: {
height: 1, height: 1,
backgroundColor: theme.colors.border, 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: { sectionLabel: {
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium, fontWeight: theme.fontWeight.medium,
@@ -697,13 +616,59 @@ const styles = StyleSheet.create((theme) => ({
paddingTop: theme.spacing[2], paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1], paddingBottom: theme.spacing[1],
}, },
checksList: { sectionList: {
paddingBottom: theme.spacing[1], 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, fontSize: theme.fontSize.sm,
flexShrink: 0,
},
listRowSecondary: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flex: 1,
minWidth: 0,
textAlign: "right",
},
listRowSpacer: {
flex: 1, flex: 1,
minWidth: 0, 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",
},
})); }));