| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { |
| 3 | AuthPreviewOperateScope, |
| 4 | type DevServerStatus, |
| 5 | type DevServerTarget, |
| 6 | type ScopedThreadRef, |
| 7 | } from "@t3tools/contracts"; |
| 8 | import * as Cause from "effect/Cause"; |
| 9 | import { GlobeIcon } from "lucide-react"; |
| 10 | import { useEffect, useEffectEvent, useRef, useState } from "react"; |
| 11 | |
| 12 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 13 | import { previewRuntimeFor } from "~/browser/previewRuntime"; |
| 14 | import { useRightPanelStore } from "~/rightPanelStore"; |
| 15 | import { devServerEnvironment } from "~/state/devServer"; |
| 16 | import { useEnvironment } from "~/state/environments"; |
| 17 | import { previewEnvironment } from "~/state/preview"; |
| 18 | import { serverEnvironment } from "~/state/server"; |
| 19 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 20 | import { useEnvironmentScope } from "~/state/session"; |
| 21 | import { openPreviewSession } from "../preview/openPreviewSession"; |
| 22 | import { Button } from "../ui/button"; |
| 23 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 24 | |
| 25 | interface Props { |
| 26 | threadRef: ScopedThreadRef; |
| 27 | target: DevServerTarget; |
| 28 | } |
| 29 | |
| 30 | export function ComposerDevServerStatus(props: Props) { |
| 31 | const config = useAtomValue(serverEnvironment.configValueAtom(props.threadRef.environmentId)); |
| 32 | const canRead = useAtomValue( |
| 33 | devServerEnvironment.status.permissionAtom(props.threadRef.environmentId), |
| 34 | ); |
| 35 | if (!config?.devServerStatus || !canRead) return null; |
| 36 | return <DevServerButton key={JSON.stringify([props.threadRef, props.target])} {...props} />; |
| 37 | } |
| 38 | |
| 39 | function DevServerButton({ threadRef, target }: Props) { |
| 40 | const connected = useEnvironment(threadRef.environmentId)?.connection.phase === "connected"; |
| 41 | const [status, setStatus] = useState<DevServerStatus | null>(null); |
| 42 | const [pending, setPending] = useState(false); |
| 43 | const [unavailable, setUnavailable] = useState(false); |
| 44 | const live = useRef(true); |
| 45 | const busy = useRef(false); |
| 46 | const load = useAtomCommand(devServerEnvironment.status, { reportFailure: false }); |
| 47 | const start = useAtomCommand(devServerEnvironment.start, { reportFailure: false }); |
| 48 | const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); |
| 49 | const canPreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); |
| 50 | const canStart = useAtomValue(devServerEnvironment.start.permissionAtom(threadRef.environmentId)); |
| 51 | const poll = useEffectEvent(async () => { |
| 52 | if (busy.current || document.visibilityState === "hidden") return true; |
| 53 | const result = await load({ environmentId: threadRef.environmentId, input: target }); |
| 54 | if (!live.current || busy.current) return true; |
| 55 | if (result._tag === "Failure") { |
| 56 | setStatus(null); |
| 57 | setUnavailable(true); |
| 58 | return false; |
| 59 | } |
| 60 | setStatus(result.value); |
| 61 | setUnavailable(false); |
| 62 | return true; |
| 63 | }); |
| 64 | |
| 65 | useEffect(() => { |
| 66 | live.current = true; |
| 67 | let active = true; |
| 68 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 69 | const refresh = async () => { |
| 70 | if (!active) return; |
| 71 | if ((await poll()) && active) timer = setTimeout(() => void refresh(), 5000); |
| 72 | }; |
| 73 | if (connected && !unavailable) void refresh(); |
| 74 | return () => { |
| 75 | active = false; |
| 76 | live.current = false; |
| 77 | clearTimeout(timer); |
| 78 | }; |
| 79 | }, [connected, unavailable]); |
| 80 | |
| 81 | const open = async () => { |
| 82 | if (!connected || busy.current) return; |
| 83 | busy.current = true; |
| 84 | setPending(true); |
| 85 | try { |
| 86 | let result = await load({ environmentId: threadRef.environmentId, input: target }); |
| 87 | if (!live.current) return; |
| 88 | if (result._tag === "Failure") throw result.cause; |
| 89 | if (!result.value.frontendUrl && result.value.canStart) { |
| 90 | result = await start({ environmentId: threadRef.environmentId, input: target }); |
| 91 | if (!live.current) return; |
| 92 | if (result._tag === "Failure") throw result.cause; |
| 93 | } |
| 94 | setStatus(result.value); |
| 95 | setUnavailable(false); |
| 96 | const frontendUrl = result.value.frontendUrl; |
| 97 | if (!frontendUrl) |
| 98 | throw new Error("Frontend unavailable. Check the dev server and try again."); |
| 99 | const url = |
| 100 | previewRuntimeFor(threadRef.environmentId) === "server" |
| 101 | ? frontendUrl |
| 102 | : resolveDiscoveredServerUrl(threadRef.environmentId, frontendUrl); |
| 103 | const opened = await openPreviewSession({ threadRef, url, openPreview }); |
| 104 | if (!live.current) return; |
| 105 | if (opened._tag === "Failure") throw opened.cause; |
| 106 | useRightPanelStore.getState().openBrowser(threadRef, opened.value.tabId); |
| 107 | } catch (cause) { |
| 108 | if (!live.current) return; |
| 109 | const error = Cause.isCause(cause) ? Cause.squash(cause) : cause; |
| 110 | setUnavailable(true); |
| 111 | toastManager.add( |
| 112 | stackedThreadToast({ |
| 113 | type: "error", |
| 114 | title: "Could not open frontend", |
| 115 | description: |
| 116 | error instanceof Error ? error.message : "Check the dev server and try again.", |
| 117 | }), |
| 118 | ); |
| 119 | } finally { |
| 120 | busy.current = false; |
| 121 | if (live.current) setPending(false); |
| 122 | } |
| 123 | }; |
| 124 | |
| 125 | if (!status && !unavailable) return null; |
| 126 | if (status && !status.canStart && status.services.length === 0) return null; |
| 127 | const ready = status?.services.filter((service) => service.status === "ready").length ?? 0; |
| 128 | const total = status?.services.length ?? 0; |
| 129 | const label = pending |
| 130 | ? "Opening…" |
| 131 | : unavailable |
| 132 | ? "Servers unavailable" |
| 133 | : status?.stopping |
| 134 | ? "Stopping…" |
| 135 | : total > 0 |
| 136 | ? `Servers ${ready}/${total}` |
| 137 | : "Start frontend"; |
| 138 | const canOpen = status?.frontendUrl || (status?.canStart && canStart) || unavailable; |
| 139 | const detail = status?.services.map((service) => `${service.id}: ${service.status}`).join(", "); |
| 140 | |
| 141 | return ( |
| 142 | <Button |
| 143 | variant="ghost-muted" |
| 144 | size="compact" |
| 145 | disabled={!connected || pending || status?.stopping || !canOpen || !canPreview} |
| 146 | aria-label={label} |
| 147 | aria-busy={pending} |
| 148 | title={detail || undefined} |
| 149 | onPointerDown={(event) => event.preventDefault()} |
| 150 | onClick={() => void open()} |
| 151 | > |
| 152 | <GlobeIcon aria-hidden="true" /> |
| 153 | <span role="status">{label}</span> |
| 154 | </Button> |
| 155 | ); |
| 156 | } |