import { Fragment, useCallback, useMemo, type ReactElement } from "react"; import type { GestureResponderEvent } from "react-native"; 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, withUnistyles } 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"; import type { Theme } from "@/styles/theme"; 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; } const ThemedPlay = withUnistyles(Play); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedExternalLink = withUnistyles(ExternalLink); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground, }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); 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 playFillTransparent = { fill: "transparent" }; interface ScriptActionButtonChildrenProps { hovered?: boolean; icon: ScriptActionIcon; label: string; } function ScriptActionButtonChildren({ hovered, icon, label, }: ScriptActionButtonChildrenProps): ReactElement { const colorMapping = hovered ? foregroundColorMapping : mutedColorMapping; const iconElement = icon === "view" ? ( ) : ( ); const labelStyle = hovered ? actionButtonLabelHoveredStyle : styles.actionButtonLabel; return ( <> {iconElement} {label} ); } function ScriptActionButton({ accessibilityLabel, disabled, icon, label, onPress, testID, }: ScriptActionButtonProps): ReactElement { const handlePress = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); onPress(); }, [onPress], ); const renderChildren = useCallback( ({ hovered }: { hovered?: boolean }) => ( ), [icon, label], ); return ( {renderChildren} ); } function stripUrlProtocol(url: string): string { return url.replace(/^https?:\/\//, ""); } interface HostLinkProps { label: string; url: string | null; scriptName: string; } interface HostLinkChildrenProps { hovered?: boolean; disabled: boolean; label: string; } function HostLinkChildren({ hovered, disabled, label }: HostLinkChildrenProps): ReactElement { const showIcon = !disabled && (hovered || isNative); const isActive = Boolean(hovered) && !disabled; const colorMapping = isActive ? foregroundColorMapping : mutedColorMapping; const hostLabelStyle = isActive ? hostLabelActiveStyle : styles.hostLabel; return ( <> {label} {showIcon ? : null} ); } function HostLinkRow({ label, url, scriptName }: HostLinkProps): ReactElement { const disabled = !url; const handlePress = useCallback( (event: GestureResponderEvent) => { event.stopPropagation(); if (url) void openExternalUrl(url); }, [url], ); const renderChildren = useCallback( ({ hovered }: { hovered?: boolean }) => ( ), [disabled, label], ); return ( {renderChildren} ); } function ExitCodeBadge({ code }: { code: number }): ReactElement { const exitTextStyle = code === 0 ? styles.exitBadgeText : exitBadgeTextErrorStyle; return ( exit {code} ); } interface HostLink { key: string; label: string; url: string | null; } interface ScriptRowProps { script: WorkspaceDescriptor["scripts"][number]; liveTerminalIdSet: Set; activeConnection: ReturnType extends infer R ? R extends { activeConnection: infer A } ? A : null : null; isStartPending: boolean; onStartScript: (scriptName: string) => void; onViewTerminal?: (terminalId: string) => void; } function resolveScriptIconColorMapping(args: { script: WorkspaceDescriptor["scripts"][number]; isService: boolean; isRunning: boolean; }): (theme: Theme) => { color: string } { const { script, isService, isRunning } = args; if (isService) { if (isRunning && script.health === "healthy") return greenColorMapping; if (isRunning && script.health === "unhealthy") return redColorMapping; if (isRunning) return blueColorMapping; return mutedColorMapping; } if (isRunning) return blueColorMapping; return mutedColorMapping; } function ScriptRow({ script, liveTerminalIdSet, activeConnection, isStartPending, onStartScript, onViewTerminal, }: ScriptRowProps): ReactElement { 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}`, }); } } } const iconColorMapping = resolveScriptIconColorMapping({ script, isService, isRunning }); const ScriptIcon = isService ? ThemedGlobe : ThemedSquareTerminal; const showExitBadge = !isRunning && exitCode !== null; const handleView = useCallback(() => { if (liveTerminalId) onViewTerminal?.(liveTerminalId); }, [liveTerminalId, onViewTerminal]); const handleRun = useCallback(() => { onStartScript(script.scriptName); }, [onStartScript, script.scriptName]); const scriptNameStyle = useMemo( () => (isRunning ? scriptNameActiveStyle : styles.scriptName), [isRunning], ); let primaryAction: ReactElement | null = null; if (isRunning && liveTerminalId) { primaryAction = ( ); } else if (!isRunning) { primaryAction = ( ); } return ( {script.scriptName} {showExitBadge ? : null} {primaryAction} {hostLinks.length > 0 ? ( {hostLinks.map((link) => ( ))} ) : null} ); } export function WorkspaceScriptsButton({ serverId, workspaceId, scripts, liveTerminalIds = [], onScriptTerminalStarted, onViewTerminal, hideLabels, }: WorkspaceScriptsButtonProps): ReactElement | null { 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); } }, }); const triggerStyle = useCallback( ({ hovered, pressed, open }: { hovered: boolean; pressed: boolean; open: boolean }) => [ styles.splitButtonPrimary, (hovered || pressed || open) && styles.splitButtonPrimaryHovered, ], [], ); const handleStartScript = useCallback( (scriptName: string) => startScriptMutation.mutate(scriptName), [startScriptMutation], ); if (scripts.length === 0) { return null; } const hasAnyRunning = scripts.some((s) => s.lifecycle === "running"); const triggerPlayMapping = hasAnyRunning ? blueColorMapping : mutedColorMapping; return ( {!hideLabels && Scripts} {scripts.map((script, index) => ( {index > 0 ? : 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, color: theme.colors.foregroundMuted, }, scriptNameActive: { color: theme.colors.foreground, }, 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, color: theme.colors.foregroundMuted, }, hostLabelActive: { color: theme.colors.foreground, }, 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, color: theme.colors.foregroundMuted, }, exitBadgeTextError: { color: theme.colors.palette.red[300], }, actionButton: { flexDirection: "row", alignItems: "center", gap: 3, }, actionButtonLabel: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, actionButtonLabelHovered: { color: theme.colors.foreground, }, })); const actionButtonLabelHoveredStyle = [styles.actionButtonLabel, styles.actionButtonLabelHovered]; const hostLabelActiveStyle = [styles.hostLabel, styles.hostLabelActive]; const scriptNameActiveStyle = [styles.scriptName, styles.scriptNameActive]; const exitBadgeTextErrorStyle = [styles.exitBadgeText, styles.exitBadgeTextError];