import { Fragment, useCallback, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View, type GestureResponderEvent } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { ExternalLink, GitMerge, GitPullRequest, GitPullRequestClosed, Globe, SquareTerminal, } from "lucide-react-native"; import type { HostBadgeModel } from "@/hosts/appearance"; import { HostBadge, HOST_BADGE_ICON_SIZE } from "@/hosts/host-badge"; import type { PrHint } from "@/git/pr-hint"; import { getForgePresentation, normalizeForge } from "@/git/forge"; import { openExternalUrl } from "@/utils/open-external-url"; import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model"; import type { Theme } from "@/styles/theme"; import { CheckIndicator } from "./check-indicator"; import type { CheckSummary, CheckSummaryState } from "./check-summary"; import { selectMetaRowItems, type MetaRowItem } from "./meta-items"; import type { WorkspaceScriptSummary } from "./script-summary"; export { selectWorkspaceScriptSummary, type WorkspaceScriptSummary } from "./script-summary"; /** * One size for every glyph on the line. The items are peers — host, change request, CI, * running service — so a glyph that differs in size reads as a different rank. The host badge * owns the size because it is the one item that also appears off this line. */ const META_ICON_SIZE = HOST_BADGE_ICON_SIZE; const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedGitMerge = withUnistyles(GitMerge); const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed); const ThemedGlobe = withUnistyles(Globe); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMutedMerged }); const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger }); /** * The subtitle under a workspace title: which host it lives on, its change request, that * change request's CI, and any running service. Everything the row knows about a workspace * that isn't its name. * * Items are peers separated by a dot rather than ranked by chrome. The host used to be a * tinted pill on the title line, which made it the loudest thing in a row whose subject is * the title; flattening it lets the whole line read as one piece of secondary text and * leaves color to mean status. */ export function WorkspaceMetaRow({ hostBadge, prHint, scriptSummary, }: { hostBadge: HostBadgeModel | null; prHint: PrHint | null; scriptSummary: WorkspaceScriptSummary | null; }) { const visible = useSidebarRowItems(); const items = selectMetaRowItems({ hasHostBadge: hostBadge !== null, prHint, scriptSummary, visible, }); if (items.length === 0) return null; return ( {items.map((item, index) => ( {index > 0 ? · : null} ))} ); } function MetaItemNode({ item, hostBadge, }: { item: MetaRowItem; hostBadge: HostBadgeModel | null; }): ReactNode { if (item.kind === "host") { return hostBadge ? : null; } if (item.kind === "changeRequest") { return ; } if (item.kind === "checks") { return ; } return ; } /** * The only thing on the line that navigates, so it is the only thing that answers the * pointer: it brightens to full foreground and its icon becomes an external-link arrow, * saying where the click goes before you spend it. * * `onHoverIn`/`onHoverOut` with local state is safe here despite the usual rule in * docs/hover.md — the state never leaves this Pressable, and nothing pressable is nested * inside it, so there is no second hover state machine to fight. Both icons are the same * size, so the swap can't move the target out from under the cursor. */ function PullRequestItem({ hint }: { hint: PrHint }) { const { t } = useTranslation(); const [isHovered, setIsHovered] = useState(false); const presentation = getForgePresentation(normalizeForge(hint.forge)); const handlePress = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); void openExternalUrl(hint.url); }, [hint.url], ); const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []); const handleHoverIn = useCallback(() => setIsHovered(true), []); const handleHoverOut = useCallback(() => setIsHovered(false), []); const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state]; return ( {hint.number} {/* An open change request is the unremarkable case and says nothing extra; a merged or closed one is why the row still looks like it has work in it. */} {hint.state === "open" ? "" : ` ${t(PR_STATE_LABEL_KEYS[hint.state])}`} ); } /** * Every state says its own name. A tick on its own has no subject — the reader has to already * know the glyph is about CI to read it at all, and sitting after a host and a change request it * reads as something left over. One short word costs less than that ambiguity. * * The words are the outcome, not the noun: "passed" against "failed", both naming how a finished * run ended, "running" for one still deciding. Repeating the subject on every healthy row — * "checks passed" — is the noise worth avoiding; naming the outcome is not. */ function ChecksItem({ summary }: { summary: CheckSummary }) { const { t } = useTranslation(); return ( {t(CHECK_STATE_LABEL_KEYS[summary.state])} ); } const CHECK_STATE_LABEL_KEYS = { passed: "workspace.git.pr.checksSummary.passedLabel", failed: "workspace.git.pr.checksSummary.failedLabel", running: "workspace.git.pr.checksSummary.runningLabel", } as const; const CHECK_STATE_ACCESSIBLE_KEYS = { passed: "workspace.git.pr.checksSummary.passedAccessible", failed: "workspace.git.pr.checksSummary.failedAccessible", running: "workspace.git.pr.checksSummary.runningAccessible", } as const; function ScriptItem({ summary }: { summary: WorkspaceScriptSummary }) { const { t } = useTranslation(); return ( {summary.kind === "service" ? ( ) : ( )} {summary.port !== null ? :{summary.port} : null} ); } const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess }); const PR_ICONS = { open: ThemedGitPullRequest, merged: ThemedGitMerge, closed: ThemedGitPullRequestClosed, } as const; // Same three colours the hover card gives the same three states — a change request has one // identity, and it shouldn't shift when you hover the row that names it. const PR_COLOR_MAPPINGS = { open: successMapping, merged: mergedMapping, closed: dangerMapping, } as const; const PR_STATE_LABEL_KEYS = { merged: "workspace.git.pr.states.merged", closed: "workspace.git.pr.states.closed", } as const; function pressableItemStyle({ pressed }: { pressed: boolean }) { return [styles.item, pressed && styles.itemPressed]; } const styles = StyleSheet.create((theme) => ({ row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1.5], minWidth: 0, }, item: { flexDirection: "row", alignItems: "center", gap: 3, minWidth: 0, flexShrink: 0, }, itemPressed: { opacity: 0.82, }, separator: { color: theme.colors.foregroundExtraMuted, fontSize: theme.fontSize.xs, lineHeight: 16, flexShrink: 0, }, scriptPort: { color: theme.colors.statusMutedSuccess, fontSize: theme.fontSize.xs, lineHeight: 16, flexShrink: 0, }, prText: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.xs, lineHeight: 16, flexShrink: 0, }, prTextHovered: { color: theme.colors.foreground, fontSize: theme.fontSize.xs, lineHeight: 16, flexShrink: 0, }, // Matches the indicator — see COLOR_MAPPINGS in check-indicator.tsx. checksTextPassed: { color: theme.colors.statusMutedSuccess, fontSize: theme.fontSize.xs, lineHeight: 16, flexShrink: 0, }, checksTextFailed: { color: theme.colors.statusMutedDanger, fontSize: theme.fontSize.xs, lineHeight: 16, flexShrink: 0, }, checksTextRunning: { color: theme.colors.statusMutedWarning, fontSize: theme.fontSize.xs, lineHeight: 16, flexShrink: 0, }, })); // Read inside render, never into a module-scope table: touching `styles.x` at module load // materializes the Unistyles proxy before the persisted theme has resolved, and the style // freezes on whatever theme happened to be active first. See docs/unistyles.md. function checksTextStyle(state: CheckSummaryState) { if (state === "failed") return styles.checksTextFailed; if (state === "running") return styles.checksTextRunning; return styles.checksTextPassed; }