diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 921ee1a649..8deae93284 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -27,6 +27,7 @@ import { onCleanup, type ParentProps, Show, + startTransition, Suspense, } from "solid-js" import { Dynamic } from "solid-js/web" @@ -47,6 +48,7 @@ import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" import { SettingsProvider } from "@/context/settings" import { TerminalProvider } from "@/context/terminal" +import { WslServersProvider } from "@/context/wsl-servers" import DirectoryLayout from "@/pages/directory-layout" import Layout from "@/pages/layout" import { ErrorPage } from "./pages/error" @@ -148,11 +150,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { }> - - - {props.children} - - + + + + {props.children} + + + @@ -215,33 +219,37 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { ) return ( - - + + { - if (checkMode() === "background") void healthCheckActions.refetch() - }} - onServerSelected={(key) => { - void withServerSwitchOverlay(() => { - batch(() => { - setCheckMode("blocking") - server.setActive(key) - }) - }) - }} - /> - } + when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"} + fallback={splash} > - {props.children} + { + if (checkMode() === "background") void healthCheckActions.refetch() + }} + onServerSelected={(key) => { + void withServerSwitchOverlay(() => + startTransition(() => { + batch(() => { + setCheckMode("blocking") + server.setActive(key) + }) + }), + ) + }} + /> + } + > + {props.children} + - - + + ) } @@ -336,6 +344,7 @@ export function AppInterface(props: { children?: JSX.Element defaultServer: ServerConnection.Key servers?: Array + serversReady?: boolean router?: Component disableHealthCheck?: boolean }) { @@ -349,6 +358,7 @@ export function AppInterface(props: { diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 10c533bef9..0206c1927f 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -8,14 +8,15 @@ import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { batch, createEffect, createMemo, createResource, onCleanup, Show, untrack } from "solid-js" +import { batch, createEffect, createMemo, onCleanup, Show, startTransition, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { DialogWslServer } from "@/components/dialog-wsl-server" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" +import { useDefaultServer } from "@/context/default-server" import { useLanguage } from "@/context/language" -import type { WslServersState } from "@/context/platform" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" +import { useWslServers } from "@/context/wsl-servers" import { isPlaceholderServerUrl, type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { withServerSwitchOverlay } from "@/utils/server-switch" @@ -75,36 +76,6 @@ function showRequestError(language: ReturnType, err: unknown }) } -function useDefaultServer() { - const language = useLanguage() - const platform = usePlatform() - const [defaultKey, defaultUrlActions] = createResource( - async () => { - try { - const key = await platform.getDefaultServer?.() - if (!key) return null - return key - } catch (err) { - showRequestError(language, err) - return null - } - }, - { initialValue: null }, - ) - - const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer) - const setDefault = async (key: ServerConnection.Key | null) => { - try { - await platform.setDefaultServer?.(key) - defaultUrlActions.mutate(key) - } catch (err) { - showRequestError(language, err) - } - } - - return { defaultKey, canDefault, setDefault } -} - function useServerPreview() { const checkServerHealth = useCheckServerHealth() @@ -208,11 +179,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const platform = usePlatform() const language = useLanguage() const { defaultKey, canDefault, setDefault } = useDefaultServer() + const wslServers = useWslServers() const { previewStatus } = useServerPreview() const checkServerHealth = useCheckServerHealth() const [store, setStore] = createStore({ status: {} as Record, - wslState: undefined as WslServersState | undefined, addServer: { url: "", name: "", @@ -329,6 +300,32 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }, })) + const removeWslMutation = useMutation(() => ({ + mutationFn: async (key: ServerConnection.Key) => { + await platform.wslServers?.removeServer(key) + return key + }, + onSuccess: async (key) => { + server.remove(key) + if (defaultKey() === key) await setDefault(null) + }, + onError: (err) => showRequestError(language, err), + })) + + const retryWslMutation = useMutation(() => ({ + mutationFn: async (key: ServerConnection.Key) => { + await platform.wslServers?.startServer(key) + }, + onError: (err) => showRequestError(language, err), + })) + + const updateWslMutation = useMutation(() => ({ + mutationFn: async (distro: string) => { + await platform.wslServers?.installOpencode(distro) + }, + onError: (err) => showRequestError(language, err), + })) + const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => { const active = server.key const newConn = server.add(next) @@ -347,7 +344,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { }) const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) - let resolvePendingWslSelection: VoidFunction | undefined + const wslState = () => wslServers.data const healthPollKey = createMemo(() => items() .map((conn) => @@ -359,7 +356,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const isSelectable = (conn: ServerConnection.Any) => !isPlaceholderServerUrl(conn.http.url) const wslRuntime = (conn: ServerConnection.Any) => { if (conn.type !== "sidecar" || conn.variant !== "wsl") return - return store.wslState?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime + return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime } const canRetryWsl = (conn: ServerConnection.Any) => { const runtime = wslRuntime(conn) @@ -406,32 +403,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { onCleanup(() => clearInterval(interval)) }) - createEffect(() => { - const api = platform.wslServers - if (!api) return - let dead = false - void api - .getState() - .then((state) => { - if (dead) return - setStore("wslState", reconcile(state)) - }) - .catch((err) => { - if (dead) return - showRequestError(language, err) - }) - const off = api.subscribe((event) => { - setStore("wslState", reconcile(event.state)) - }) - onCleanup(() => { - dead = true - off() - }) - }) - const wslCheck = (conn: ServerConnection.Any) => { if (conn.type !== "sidecar" || conn.variant !== "wsl") return null - return store.wslState?.opencodeChecks[conn.distro] ?? null + return wslState()?.opencodeChecks[conn.distro] ?? null } const displayVersion = (conn: ServerConnection.Any) => { @@ -441,34 +415,35 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { async function select(conn: ServerConnection.Any, persist?: boolean) { if (!isSelectable(conn)) return - if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return + if (!persist && health(ServerConnection.key(conn))?.healthy === false) return const nextKey = ServerConnection.key(conn) const changed = server.key !== nextKey - const apply = () => { - dialog.close() - if (persist && conn.type === "http") { - server.add(conn) - if (changed && typeof window !== "undefined" && window.history?.replaceState) { - window.history.replaceState(null, "", "/") - } else { - props.onNavigateHome?.() - } + const navigateHome = () => { + if (changed && typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") return } - - batch(() => { - if (changed && typeof window !== "undefined" && window.history?.replaceState) { - window.history.replaceState(null, "", "/") - } else { - props.onNavigateHome?.() - } - server.setActive(nextKey) - }) + props.onNavigateHome?.() } + const apply = () => + startTransition(() => { + dialog.close() + if (persist && conn.type === "http") { + server.add(conn) + navigateHome() + return + } + + batch(() => { + navigateHome() + server.setActive(nextKey) + }) + }) + if (!changed) { - apply() + await apply() return } @@ -480,10 +455,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { if (!key) return const conn = items().find((item) => ServerConnection.key(item) === key) if (!conn || !isSelectable(conn)) return - const resolve = resolvePendingWslSelection - resolvePendingWslSelection = undefined setStore("addWsl", "pendingSelectKey", undefined) - void select(conn).finally(() => resolve?.()) + void select(conn) }) const handleAddChange = (value: string) => { @@ -559,8 +532,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const resetForm = () => { resetAdd() resetEdit() - resolvePendingWslSelection?.() - resolvePendingWslSelection = undefined setStore("addWsl", "pendingSelectKey", undefined) setStore("addWsl", "showWizard", false) } @@ -589,7 +560,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { username: conn.http.username ?? "", password: conn.http.password ?? "", error: "", - status: store.status[ServerConnection.key(conn)]?.healthy, + status: health(ServerConnection.key(conn))?.healthy, }) } @@ -602,15 +573,13 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const handleAddedWsl = async (distro: string) => { const key = ServerConnection.Key.make(`wsl:${distro}`) + setStore("addWsl", "showWizard", false) + setStore("addWsl", "pendingSelectKey", key) const conn = items().find((item) => ServerConnection.key(item) === key) if (conn && isSelectable(conn)) { await select(conn) - return + setStore("addWsl", "pendingSelectKey", undefined) } - await new Promise((resolve) => { - resolvePendingWslSelection = resolve - setStore("addWsl", "pendingSelectKey", key) - }) } const submitForm = () => { @@ -657,44 +626,22 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { async function handleRemove(key: ServerConnection.Key) { server.remove(key) - if ((await platform.getDefaultServer?.()) === key) { - void platform.setDefaultServer?.(null) - } + if (defaultKey() === key) await setDefault(null) } - async function handleRemoveWsl(conn: ServerConnection.Any) { + function handleRemoveWsl(conn: ServerConnection.Any) { if (conn.type !== "sidecar" || conn.variant !== "wsl") return - const key = ServerConnection.key(conn) - try { - await platform.wslServers?.removeServer(key) - server.remove(key) - if ((await platform.getDefaultServer?.()) === key) { - void platform.setDefaultServer?.(null) - } - } catch (err) { - showRequestError(language, err) - } + removeWslMutation.mutate(ServerConnection.key(conn)) } - async function handleRetryWsl(conn: ServerConnection.Any) { + function handleRetryWsl(conn: ServerConnection.Any) { if (conn.type !== "sidecar" || conn.variant !== "wsl") return - try { - await platform.wslServers?.startServer(ServerConnection.key(conn)) - } catch (err) { - showRequestError(language, err) - } + retryWslMutation.mutate(ServerConnection.key(conn)) } - async function handleUpdateWsl(conn: ServerConnection.Any) { + function handleUpdateWsl(conn: ServerConnection.Any) { if (conn.type !== "sidecar" || conn.variant !== "wsl") return - const api = platform.wslServers - if (!api) return - try { - await api.installOpencode(conn.distro) - await refreshHealth() - } catch (err) { - showRequestError(language, err) - } + updateWslMutation.mutate(conn.distro) } return ( @@ -753,7 +700,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { const isWslSidecar = i.type === "sidecar" && i.variant === "wsl" const wslDistro = i.type === "sidecar" && i.variant === "wsl" ? i.distro : undefined const blocked = () => !isSelectable(i) || health(key)?.healthy === false - const hasMenuActionsBeforeDelete = () => i.type === "http" || (isWslSidecar && canRetryWsl(i)) + const canChangeDefault = () => canDefault() && i.type !== "ssh" + const canRemove = () => i.type === "http" || isWslSidecar + const hasMenuActionsBeforeDelete = () => canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i)) const outdated = () => { const check = wslCheck(i) return versionOlderThan(check?.version, check?.expectedVersion) @@ -765,8 +714,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { if (outdated()) return "Update OpenCode" return null } - const updating = () => - store.wslState?.job?.kind === "install-opencode" && store.wslState.job.distro === wslDistro + const updating = () => { + const job = wslState()?.job + return job?.kind === "install-opencode" && job.distro === wslDistro + } return ( @@ -793,12 +744,12 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { e.stopPropagation()} onClick={(e: MouseEvent) => { e.stopPropagation() - void handleUpdateWsl(i) + handleUpdateWsl(i) }} > {updating() ? "Updating OpenCode..." : label()} @@ -809,7 +760,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { - + - void handleRetryWsl(i)}> + handleRetryWsl(i)}> Retry start - - setDefault(key)}> + + void setDefault(key)}> {language.t("dialog.server.menu.default")} - - setDefault(null)}> + + void setDefault(null)}> {language.t("dialog.server.menu.defaultRemove")} @@ -853,9 +804,15 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { - + (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))} + onSelect={() => { + if (isWslSidecar) { + handleRemoveWsl(i) + return + } + void handleRemove(key) + }} class="text-text-on-critical-base hover:bg-surface-critical-weak" > diff --git a/packages/app/src/components/dialog-wsl-server.tsx b/packages/app/src/components/dialog-wsl-server.tsx index 4c4cf952c6..80ec8a49da 100644 --- a/packages/app/src/components/dialog-wsl-server.tsx +++ b/packages/app/src/components/dialog-wsl-server.tsx @@ -1,12 +1,14 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Spinner } from "@opencode-ai/ui/spinner" +import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" -import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js" -import { createStore, reconcile } from "solid-js/store" +import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js" +import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" -import type { WslServerStep, WslServersState } from "@/context/platform" +import type { WslServerStep } from "@/context/platform" import { usePlatform } from "@/context/platform" +import { useWslServers } from "@/context/wsl-servers" const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"] @@ -30,42 +32,15 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { const language = useLanguage() const platform = usePlatform() const dialog = useDialog() + const wslServers = useWslServers() const [store, setStore] = createStore({ - state: undefined as WslServersState | undefined, - loading: true, step: undefined as WslServerStep | undefined, selectedDistro: null as string | null, installTarget: undefined as string | undefined, adding: false, }) - - createEffect(() => { - const wslServers = platform.wslServers - if (!wslServers) return - let mounted = true - void wslServers - .getState() - .then((state) => { - if (!mounted) return - setStore({ state, loading: false }) - }) - .catch((err) => { - if (!mounted) return - requestError(language, err) - setStore("loading", false) - }) - const off = wslServers.subscribe((event) => { - setStore("state", reconcile(event.state)) - setStore("loading", false) - }) - onCleanup(() => { - mounted = false - off() - }) - }) - - const current = () => store.state - const wslServers = () => platform.wslServers + const current = () => wslServers.data + const wslApi = () => platform.wslServers const busy = createMemo(() => !!current()?.job || store.adding) const selectedDistro = () => store.selectedDistro const selectedProbe = createMemo(() => { @@ -156,22 +131,94 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { // them back when a probe result updates recommendedStep. const activeStep = createMemo(() => store.step ?? recommendedStep()) + const probeRuntimeMutation = useMutation(() => ({ + mutationFn: async () => { + const api = wslApi() + if (!api) return + await api.probeRuntime() + }, + })) + + const refreshDistrosMutation = useMutation(() => ({ + mutationFn: async () => { + const api = wslApi() + if (!api) return + await api.refreshDistros() + }, + })) + + const installWslMutation = useMutation(() => ({ + mutationFn: async () => { + const api = wslApi() + if (!api) return + await api.installWsl() + }, + })) + + const installDistroMutation = useMutation(() => ({ + mutationFn: async (name: string) => { + const api = wslApi() + if (!api) return + await api.installDistro(name) + }, + })) + + const probeDistroMutation = useMutation(() => ({ + mutationFn: async (name: string) => { + const api = wslApi() + if (!api) return + await api.probeDistro(name) + }, + })) + + const probeOpencodeMutation = useMutation(() => ({ + mutationFn: async (name: string) => { + const api = wslApi() + if (!api) return + await api.probeOpencode(name) + }, + })) + + const installOpencodeMutation = useMutation(() => ({ + mutationFn: async (name: string) => { + const api = wslApi() + if (!api) return + await api.installOpencode(name) + }, + })) + + const openTerminalMutation = useMutation(() => ({ + mutationFn: async (name: string) => { + const api = wslApi() + if (!api) return + await api.openTerminal(name) + }, + })) + + const addServerMutation = useMutation(() => ({ + mutationFn: async (distro: string) => { + const api = wslApi() + if (!api) return + return api.addServer(distro) + }, + })) + const autoProbe = createMemo(() => { const state = current() - if (!state || !wslServers() || busy()) return null + if (!state || !wslApi() || busy()) return null if (state.pendingRestart) return null - if (!state.runtime) return { key: "runtime", run: () => wslServers()!.probeRuntime() } + if (!state.runtime) return { key: "runtime", run: () => probeRuntimeMutation.mutateAsync() } if (!wslReady()) return null if (!state.installed.length && !state.online.length) { - return { key: "distros", run: () => wslServers()!.refreshDistros() } + return { key: "distros", run: () => refreshDistrosMutation.mutateAsync() } } const distro = selectedDistro() if (distro && !state.distroProbes[distro]) { - return { key: `probe-distro:${distro}`, run: () => wslServers()!.probeDistro(distro) } + return { key: `probe-distro:${distro}`, run: () => probeDistroMutation.mutateAsync(distro) } } if (!distro || !distroReady()) return null if (!state.opencodeChecks[distro]) { - return { key: `probe-opencode:${distro}`, run: () => wslServers()!.probeOpencode(distro) } + return { key: `probe-opencode:${distro}`, run: () => probeOpencodeMutation.mutateAsync(distro) } } return null }) @@ -278,11 +325,9 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { const finish = async () => { const distro = selectedDistro() if (!distro) return - const api = wslServers() - if (!api) return setStore("adding", true) try { - await api.addServer(distro) + await addServerMutation.mutateAsync(distro) if (props.onAdded) { await props.onAdded(distro) } else { @@ -309,32 +354,38 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { locked: stepIndex(step) > stepIndex(recommendedStep()), })), ) + const loadError = createMemo(() => { + const error = wslServers.error + if (!error) return "Failed to load WSL state." + return error instanceof Error ? error.message : String(error) + }) return ( - Loading...}> - - - {(item) => ( - setStore("step", item.step)} - > - {item.title} - - )} - - + Loading...}> + {loadError()}}> + + + {(item) => ( + setStore("step", item.step)} + > + {item.title} + + )} + + - + @@ -344,7 +395,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="large" disabled={busy()} - onClick={() => void run(() => wslServers()!.installWsl())} + onClick={() => void run(() => installWslMutation.mutateAsync())} > Install WSL @@ -355,7 +406,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { Windows restart required. void platform.restart()}> - Restart OpenCode + Relaunch OpenCode @@ -369,7 +420,23 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { - Choose a distro + + Choose a distro + + { + const distro = selectedDistro() + if (!distro) return + void run(() => probeDistroMutation.mutateAsync(distro)) + }} + > + Refresh + + + {distroMessage()} @@ -420,7 +487,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { variant="secondary" size="small" disabled={busy() || !installTarget()} - onClick={() => void run(() => wslServers()!.installDistro(installTarget()!.name))} + onClick={() => void run(() => installDistroMutation.mutateAsync(installTarget()!.name))} > {installingDistro() ? "Installing..." : "Install"} @@ -488,18 +555,32 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { - { - const distro = selectedDistro() - if (!distro) return - void run(() => wslServers()!.openTerminal(distro)) - }} - > - Open terminal - + + { + const distro = selectedDistro() + if (!distro) return + void run(() => openTerminalMutation.mutateAsync(distro)) + }} + > + Open terminal + + { + const distro = selectedDistro() + if (!distro) return + void run(() => probeDistroMutation.mutateAsync(distro)) + }} + > + Refresh + + OpenCode - - { - const distro = selectedDistro() - if (!distro) return - void run(() => wslServers()!.installOpencode(distro)) - }} - > - {opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"} - - + + + { + const distro = selectedDistro() + if (!distro) return + void run(() => probeOpencodeMutation.mutateAsync(distro)) + }} + > + Refresh + + + + { + const distro = selectedDistro() + if (!distro) return + void run(() => installOpencodeMutation.mutateAsync(distro)) + }} + > + {opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"} + + + {opencodeMessage()} @@ -552,58 +649,59 @@ export function DialogWslServer(props: DialogWslServerProps = {}) { - + - - {(progress) => ( + + {(progress) => ( + + + + Progress + + {progress().title} + + + {(line) => ( + + {line.text} + + )} + + + + )} + + + 0}> - - - Progress - - {progress().title} - - - {(line) => ( - - {line.text} - - )} - + Diagnostics + + {(line) => {line.text}} - )} - + - 0}> - - Diagnostics - - {(line) => {line.text}} + + + dialog.close()}> + Cancel + + void finish()}> + {store.adding ? "Adding..." : "Add WSL server"} + - - - - - - dialog.close()}> - Cancel - - void finish()}> - {store.adding ? "Adding..." : "Add WSL server"} - - + diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index bc257df618..290ec35194 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -6,11 +6,11 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { useMutation } from "@tanstack/solid-query" import { showToast } from "@opencode-ai/ui/toast" import { useNavigate } from "@solidjs/router" -import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, untrack } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, startTransition, untrack } from "solid-js" import { createStore, reconcile } from "solid-js/store" import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row" +import { useDefaultServer } from "@/context/default-server" import { useLanguage } from "@/context/language" -import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" @@ -97,49 +97,6 @@ const useServerHealth = (servers: Accessor, enabled: Acc return status } -const useDefaultServerKey = ( - get: (() => string | Promise | null | undefined) | undefined, -) => { - const [state, setState] = createStore({ - key: undefined as ServerConnection.Key | undefined, - tick: 0, - }) - - createEffect(() => { - state.tick - let dead = false - const result = get?.() - if (!result) { - setState("key", undefined) - onCleanup(() => { - dead = true - }) - return - } - - if (result instanceof Promise) { - void result.then((next) => { - if (dead) return - setState("key", next ? ServerConnection.Key.make(next) : undefined) - }) - onCleanup(() => { - dead = true - }) - return - } - - setState("key", ServerConnection.Key.make(result)) - onCleanup(() => { - dead = true - }) - }) - - return { - key: () => state.key, - refresh: () => setState("tick", (value) => value + 1), - } -} - const useMcpToggleMutation = () => { const sync = useSync() const sdk = useSDK() @@ -165,11 +122,11 @@ const useMcpToggleMutation = () => { export function StatusPopoverBody(props: { shown: Accessor }) { const sync = useSync() const server = useServer() - const platform = usePlatform() const dialog = useDialog() const language = useLanguage() const navigate = useNavigate() const sdk = useSDK() + const defaultServer = useDefaultServer() const [load, setLoad] = createStore({ lspDone: false, @@ -240,7 +197,6 @@ export function StatusPopoverBody(props: { shown: Accessor }) { const health = useServerHealth(servers, props.shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() - const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length) @@ -296,23 +252,25 @@ export function StatusPopoverBody(props: { shown: Accessor }) { "hover:bg-surface-raised-base-hover": !blocked(), "cursor-not-allowed": blocked(), }} - aria-disabled={blocked()} - onClick={() => { - if (blocked()) return - void withServerSwitchOverlay(() => { - batch(() => { - if (server.key !== key) { - if (typeof window !== "undefined" && window.history?.replaceState) { - window.history.replaceState(null, "", "/") - } - } else { - navigate("/") - } - server.setActive(key) - }) - }) - }} - > + aria-disabled={blocked()} + onClick={() => { + if (blocked()) return + void withServerSwitchOverlay(() => + startTransition(() => { + batch(() => { + if (server.key !== key) { + if (typeof window !== "undefined" && window.history?.replaceState) { + window.history.replaceState(null, "", "/") + } + } else { + navigate("/") + } + server.setActive(key) + }) + }), + ) + }} + > }) { nameClass="text-14-regular text-text-base truncate" versionClass="text-12-regular text-text-weak truncate" badge={ - + {language.t("common.default")} @@ -348,7 +306,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { if (dialogDead || dialogRun !== run) return dialog.show( () => navigate("/")} />, - defaultServer.refresh, + () => void defaultServer.query.refetch(), ) }) }} diff --git a/packages/app/src/context/default-server.tsx b/packages/app/src/context/default-server.tsx new file mode 100644 index 0000000000..48c360f5e1 --- /dev/null +++ b/packages/app/src/context/default-server.tsx @@ -0,0 +1,51 @@ +import { showToast } from "@opencode-ai/ui/toast" +import { queryOptions, skipToken, useMutation, useQuery, useQueryClient } from "@tanstack/solid-query" +import { useLanguage } from "./language" +import { usePlatform } from "./platform" +import { ServerConnection } from "./server" + +const defaultServerQueryKey = ["platform", "defaultServer"] as const + +function defaultServerQueryOptions(getDefaultServer: ReturnType["getDefaultServer"]) { + return queryOptions({ + queryKey: defaultServerQueryKey, + queryFn: getDefaultServer + ? () => getDefaultServer().then((next) => (next ? ServerConnection.Key.make(next) : null)) + : skipToken, + staleTime: Number.POSITIVE_INFINITY, + }) +} + +export function useDefaultServer() { + const language = useLanguage() + const platform = usePlatform() + const queryClient = useQueryClient() + const query = useQuery(() => ({ ...defaultServerQueryOptions(platform.getDefaultServer) })) + const mutation = useMutation(() => ({ + mutationFn: async (key: ServerConnection.Key | null) => { + if (!platform.setDefaultServer) return key + await platform.setDefaultServer(key) + return key + }, + onSuccess: (key) => { + queryClient.setQueryData(defaultServerQueryKey, key) + }, + onError: (err) => { + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), + }) + }, + })) + + return { + canDefault: () => !!platform.getDefaultServer && !!platform.setDefaultServer, + defaultKey: () => query.data ?? null, + query, + setDefault(key: ServerConnection.Key | null) { + if (!platform.setDefaultServer) return Promise.resolve(key) + return mutation.mutateAsync(key) + }, + } +} diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 9063864c6c..62fe040b2a 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -97,9 +97,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( init: (props: { defaultServer: ServerConnection.Key disableHealthCheck?: boolean + serversReady?: boolean servers?: Array }) => { const checkServerHealth = useCheckServerHealth() + const serversReady = () => props.serversReady ?? true const [store, setStore, _, ready] = persisted( Persist.global("server", ["server.v3"]), @@ -204,8 +206,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) } - const isReady = createMemo(() => ready() && !!state.active) - const check = (conn: ServerConnection.Any) => checkServerHealth(conn.http).then((x) => { if (!x.healthy) { @@ -229,14 +229,19 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) - const current: Accessor = createMemo( - () => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0], - ) + const current: Accessor = createMemo(() => { + const list = allServers() + const active = list.find((s) => ServerConnection.key(s) === state.active) + if (active) return active + if (!serversReady()) return + return list[0] + }) const healthTarget = createMemo(() => { const conn = current() if (!conn) return "" return [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n") }) + const isReady = createMemo(() => ready() && !!current()) createEffect(() => { healthTarget() @@ -257,6 +262,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) createEffect(() => { + if (!serversReady()) return const list = allServers() if (!list.length) return if (list.some((conn) => ServerConnection.key(conn) === state.active)) return diff --git a/packages/app/src/context/wsl-servers.tsx b/packages/app/src/context/wsl-servers.tsx new file mode 100644 index 0000000000..0668c905f6 --- /dev/null +++ b/packages/app/src/context/wsl-servers.tsx @@ -0,0 +1,36 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { queryOptions, skipToken, useQuery, useQueryClient } from "@tanstack/solid-query" +import { createEffect, onCleanup } from "solid-js" +import type { WslServersPlatform, WslServersState } from "./platform" +import { usePlatform } from "./platform" + +export const wslServersQueryKey = ["platform", "wslServers"] as const + +export function wslServersQueryOptions(api: WslServersPlatform | undefined) { + return queryOptions({ + queryKey: wslServersQueryKey, + queryFn: api ? () => api.getState() : skipToken, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + }) +} + +export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({ + name: "WslServers", + init: () => { + const platform = usePlatform() + const queryClient = useQueryClient() + const query = useQuery(() => ({ ...wslServersQueryOptions(platform.wslServers) })) + + createEffect(() => { + const api = platform.wslServers + if (!api) return + const off = api.subscribe((event) => { + queryClient.setQueryData(wslServersQueryKey, event.state) + }) + onCleanup(off) + }) + + return query + }, +}) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 4173cf9ca7..b960238e9a 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -3,6 +3,7 @@ export { DialogWslServer } from "./components/dialog-wsl-server" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" +export { useWslServers } from "./context/wsl-servers" export { type DisplayBackend, type Platform, diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index f7342e9bea..be0be3cf59 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -437,59 +437,39 @@ render(() => { const [defaultServer] = createResource(() => platform.getDefaultServer?.().then((url) => { - if (url) return ServerConnection.key({ type: "http", http: { url } }) + if (url) return ServerConnection.Key.make(url) }), ) const [locale] = createResource(loadLocale) - - const [wslServers, setWslServers] = createSignal(null) + const [storedServers] = createResource(async () => { + const raw = await platform.storage?.("opencode.global.dat").getItem("server") + if (!raw) return [] + try { + const parsed = JSON.parse(raw) as { list?: unknown } + return Array.isArray(parsed.list) ? parsed.list : [] + } catch { + return [] + } + }) + const [wslServers, setWslServers] = createSignal() + const [wslReady, setWslReady] = createSignal(!platform.wslServers) if (platform.wslServers) { - void platform.wslServers.getState().then((state) => setWslServers(state)) - const off = platform.wslServers.subscribe((event: WslServersEvent) => setWslServers(event.state)) + void platform.wslServers + .getState() + .then((state) => { + setWslServers(state) + setWslReady(true) + }) + .catch(() => { + setWslReady(true) + }) + const off = platform.wslServers.subscribe((event: WslServersEvent) => { + setWslServers(event.state) + setWslReady(true) + }) onCleanup(off) } - const servers = createMemo(() => { - const data = startup.latest?.sidecar - const list: ServerConnection.Any[] = [] - if (data) { - list.push({ - displayName: "Local Server", - type: "sidecar", - variant: "base", - http: { - url: data.local.url, - username: data.local.username ?? undefined, - password: data.local.password ?? undefined, - }, - }) - } - const wsl = wslServers() - if (wsl) { - for (const item of wsl.servers) { - const runtime = item.runtime - const http = - runtime.kind === "ready" - ? { - url: runtime.url, - username: runtime.username ?? undefined, - password: runtime.password ?? undefined, - } - : { - url: `http://wsl-${item.config.distro}.invalid`, - } - list.push({ - displayName: item.config.distro, - type: "sidecar", - variant: "wsl", - distro: item.config.distro, - http, - }) - } - } - return list - }) - function handleClick(e: MouseEvent) { const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null if (link?.href) { @@ -516,6 +496,76 @@ render(() => { return null } + function App() { + const splash = ( + + + + ) + + const ready = createMemo( + () => + !defaultServer.loading && + !startup.loading && + !windowCount.loading && + !locale.loading, + ) + const servers = createMemo(() => { + const data = startup.latest?.sidecar + const list: ServerConnection.Any[] = [] + if (data) { + list.push({ + displayName: "Local Server", + type: "sidecar", + variant: "base", + http: { + url: data.local.url, + username: data.local.username ?? undefined, + password: data.local.password ?? undefined, + }, + }) + } + for (const item of wslServers()?.servers ?? []) { + const runtime = item.runtime + list.push({ + displayName: item.config.distro, + type: "sidecar", + variant: "wsl", + distro: item.config.distro, + http: + runtime.kind === "ready" + ? { + url: runtime.url, + username: runtime.username ?? undefined, + password: runtime.password ?? undefined, + } + : { url: `http://wsl-${item.config.distro}.invalid` }, + }) + } + return list + }) + const hasFallbackServers = createMemo(() => { + if ((storedServers.latest?.length ?? 0) > 0) return true + return (wslServers()?.servers.length ?? 0) > 0 + }) + + if (!ready()) return splash + if (startup.latest?.error && !storedServers.loading && !hasFallbackServers()) { + return + } + + return ( + + + + ) + } + onMount(() => { document.addEventListener("click", handleClick) onCleanup(() => { @@ -526,25 +576,7 @@ render(() => { return ( - - {(_) => { - if (startup.latest?.error) { - return - } - return ( - - - - ) - }} - + )