import { type ReactElement } from "react"; import { Pressable, Text, View } from "react-native"; import { useMutation } from "@tanstack/react-query"; import { ChevronDown, ExternalLink, Globe, LoaderCircle, 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, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useToast } from "@/contexts/toast-context"; import { openExternalUrl } from "@/utils/open-external-url"; import { resolveWorkspaceScriptLink } from "@/utils/workspace-script-links"; type Script = WorkspaceDescriptor["scripts"][number]; function getScriptHealthColor( health: Script["health"], theme: ReturnType["theme"], ): string { if (health === "healthy") { return theme.colors.palette.blue[500]; } if (health === "unhealthy") { return theme.colors.palette.red[500]; } return theme.colors.foregroundMuted; } interface WorkspaceScriptsButtonProps { serverId: string; workspaceId: string; scripts: WorkspaceDescriptor["scripts"]; } export function WorkspaceScriptsButton({ serverId, workspaceId, scripts, }: 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 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" }, ); }, }); 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" > Scripts {scripts.map((script) => { const isRunning = script.lifecycle === "running"; const isService = (script.type ?? "service") === "service"; const serviceLink = resolveWorkspaceScriptLink({ script, activeConnection }); const isLinkable = isService && isRunning && !!serviceLink.openUrl; const exitCode = script.exitCode ?? null; let dotColor: string; if (isService) { dotColor = isRunning ? getScriptHealthColor(script.health, theme) : theme.colors.foregroundMuted; } else if (isRunning) { dotColor = theme.colors.palette.blue[500]; } else if (exitCode === 0) { dotColor = theme.colors.palette.green[500]; } else if (exitCode !== null) { dotColor = theme.colors.palette.red[500]; } else { dotColor = theme.colors.foregroundMuted; } return ( [ styles.scriptRow, hovered && isLinkable && styles.scriptRowHovered, ]} onPress={ isLinkable ? () => void openExternalUrl(serviceLink.openUrl!) : undefined } disabled={!isLinkable} > {({ hovered }) => ( <> {isService ? ( ) : ( )} {script.scriptName} {isService && isRunning && serviceLink.labelUrl ? ( {serviceLink.labelUrl.replace(/^https?:\/\//, "")} ) : !isService && !isRunning && exitCode !== null && exitCode !== 0 ? ( exit {exitCode} ) : ( )} {isRunning ? ( isLinkable && hovered ? ( ) : null ) : ( { event.stopPropagation(); startScriptMutation.mutate(script.scriptName); }} style={styles.startButton} > {({ hovered: actionHovered }) => startScriptMutation.isPending && startScriptMutation.variables === script.scriptName ? ( ) : ( <> Run ) } )} )} ); })} ); } 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], }, scriptRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: 6, minHeight: 28, }, scriptRowHovered: { backgroundColor: theme.colors.surface2, }, scriptIcon: { flexShrink: 0, }, scriptName: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, lineHeight: 18, flexShrink: 0, }, scriptUrl: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, lineHeight: 18, flex: 1, minWidth: 0, textAlign: "right", }, spacer: { flex: 1, minWidth: 0, }, externalLinkOverlay: { position: "absolute", right: 0, top: 0, bottom: 0, paddingLeft: theme.spacing[4], paddingRight: theme.spacing[3], alignItems: "center", justifyContent: "center", }, startButton: { flexDirection: "row", alignItems: "center", gap: 3, }, startButtonLabel: { fontSize: theme.fontSize.xs, fontWeight: theme.fontWeight.normal, }, }));