import { Fragment, useMemo, type ReactElement } from "react"; import { Pressable, Text, View } from "react-native"; import { useMutation } from "@tanstack/react-query"; import { ChevronDown, ExternalLink, Globe, Play, SquareTerminal } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import type { WorkspaceDescriptor } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { DropdownMenu, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/contexts/toast-context"; import { isNative } from "@/constants/platform"; import { openExternalUrl } from "@/utils/open-external-url"; import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links"; type ScriptActionIcon = "start" | "view"; interface WorkspaceScriptsButtonProps { serverId: string; workspaceId: string; scripts: WorkspaceDescriptor["scripts"]; liveTerminalIds?: readonly string[]; onScriptTerminalStarted?: (terminalId: string) => void; onViewTerminal?: (terminalId: string) => void; hideLabels?: boolean; } interface ScriptActionButtonProps { accessibilityLabel: string; disabled?: boolean; icon: ScriptActionIcon; label: string; onPress: () => void; testID: string; } function ScriptActionButton({ accessibilityLabel, disabled, icon, label, onPress, testID, }: ScriptActionButtonProps): ReactElement { const { theme } = useUnistyles(); return ( { event.stopPropagation(); onPress(); }} style={styles.actionButton} > {({ hovered }) => { const color = hovered ? theme.colors.foreground : theme.colors.foregroundMuted; const iconProps = { size: 10, color }; let iconElement: ReactElement; if (icon === "view") { iconElement = ; } else { iconElement = ; } return ( <> {iconElement} {label} ); }} ); } function stripUrlProtocol(url: string): string { return url.replace(/^https?:\/\//, ""); } interface HostLinkProps { label: string; url: string | null; scriptName: string; } function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement { const { theme } = useUnistyles(); const disabled = !url; return ( { event.stopPropagation(); if (url) void openExternalUrl(url); }} style={styles.hostRow} > {({ hovered }) => { const showIcon = !disabled && (hovered || isNative); const color = hovered && !disabled ? theme.colors.foreground : theme.colors.foregroundMuted; return ( <> {label} {showIcon ? : null} ); }} ); } function ExitCodeBadge({ code }: { code: number }): ReactElement { const { theme } = useUnistyles(); const color = code === 0 ? theme.colors.foregroundMuted : theme.colors.palette.red[300]; return ( exit {code} ); } interface HostLink { key: string; label: string; url: string | null; } export function WorkspaceScriptsButton({ serverId, workspaceId, scripts, liveTerminalIds = [], onScriptTerminalStarted, onViewTerminal, hideLabels, }: WorkspaceScriptsButtonProps): ReactElement | null { const { theme } = useUnistyles(); const toast = useToast(); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const activeConnection = useHostRuntimeSnapshot(serverId)?.activeConnection ?? null; const liveTerminalIdSet = useMemo(() => new Set(liveTerminalIds), [liveTerminalIds]); const startScriptMutation = useMutation({ mutationFn: async (scriptName: string) => { if (!client) { throw new Error("Daemon client not available"); } const result = await client.startWorkspaceScript(workspaceId, scriptName); if (result.error) { throw new Error(result.error); } return result; }, onError: (error, scriptName) => { toast.show(error instanceof Error ? error.message : `Failed to start ${scriptName}`, { variant: "error", }); }, onSuccess: (result) => { if (result.terminalId) { onScriptTerminalStarted?.(result.terminalId); } }, }); if (scripts.length === 0) { return null; } const hasAnyRunning = scripts.some((s) => s.lifecycle === "running"); return ( [ styles.splitButtonPrimary, (hovered || pressed || open) && styles.splitButtonPrimaryHovered, ]} accessibilityRole="button" accessibilityLabel="Workspace scripts" > {!hideLabels && Scripts} {scripts.map((script, index) => { const isRunning = script.lifecycle === "running"; const isService = (script.type ?? "service") === "service"; const exitCode = script.exitCode ?? null; const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection }); const serviceOpenUrl = isService && isRunning ? serviceLink.openUrl : null; const liveTerminalId = script.terminalId && liveTerminalIdSet.has(script.terminalId) ? script.terminalId : null; const hostLinks: HostLink[] = []; if (isService && isRunning) { const routedUrl = script.proxyUrl ?? serviceLink.labelUrl; if (routedUrl) { hostLinks.push({ key: "proxy", label: stripUrlProtocol(routedUrl), url: serviceOpenUrl, }); } if (script.port !== null) { const localhostLabel = `localhost:${script.port}`; const alreadyShown = hostLinks.some((l) => l.label === localhostLabel); if (!alreadyShown) { hostLinks.push({ key: "localhost", label: localhostLabel, url: `http://localhost:${script.port}`, }); } } } let iconColor = theme.colors.foregroundMuted; if (isService) { if (isRunning && script.health === "healthy") { iconColor = theme.colors.palette.green[500]; } else if (isRunning && script.health === "unhealthy") { iconColor = theme.colors.palette.red[500]; } else if (isRunning) { iconColor = theme.colors.palette.blue[500]; } } else if (isRunning) { iconColor = theme.colors.palette.blue[500]; } const ScriptIcon = isService ? Globe : SquareTerminal; const showExitBadge = !isRunning && exitCode !== null; let primaryAction: ReactElement | null = null; if (isRunning && liveTerminalId) { primaryAction = ( onViewTerminal?.(liveTerminalId)} /> ); } else if (!isRunning) { primaryAction = ( startScriptMutation.mutate(script.scriptName)} /> ); } return ( {index > 0 ? : null} {script.scriptName} {showExitBadge ? : null} {primaryAction} {hostLinks.length > 0 ? ( {hostLinks.map((link) => ( ))} ) : null} ); })} ); } const styles = StyleSheet.create((theme) => ({ row: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], flexShrink: 0, }, splitButton: { flexDirection: "row", alignItems: "stretch", borderRadius: theme.borderRadius.md, borderWidth: theme.borderWidth[1], borderColor: theme.colors.borderAccent, overflow: "hidden", }, splitButtonPrimary: { paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[1], justifyContent: "center", }, splitButtonPrimaryHovered: { backgroundColor: theme.colors.surface2, }, splitButtonText: { fontSize: theme.fontSize.sm, lineHeight: theme.fontSize.sm * 1.5, color: theme.colors.foreground, fontWeight: theme.fontWeight.normal, }, splitButtonContent: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[1.5], }, scriptList: { paddingVertical: theme.spacing[1], }, scriptItem: { paddingVertical: 6, }, scriptHeader: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], minHeight: 24, }, scriptIcon: { flexShrink: 0, }, scriptName: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, lineHeight: 18, flexShrink: 1, minWidth: 0, }, spacer: { flex: 1, minWidth: 0, }, hostList: { marginTop: 2, paddingHorizontal: theme.spacing[3], }, hostRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1.5], paddingVertical: 2, minHeight: 18, }, hostLabel: { flexShrink: 1, fontSize: theme.fontSize.xs, lineHeight: 14, }, hostIconSlot: { width: 10, height: 10, alignItems: "center", justifyContent: "center", flexShrink: 0, }, exitBadge: { paddingHorizontal: theme.spacing[1.5], paddingVertical: 1, borderRadius: 2, backgroundColor: theme.colors.surface2, }, exitBadgeText: { fontSize: 10, lineHeight: 12, fontWeight: theme.fontWeight.medium, }, actionButton: { flexDirection: "row", alignItems: "center", gap: 3, }, actionButtonLabel: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, }));