This commit is contained in:
LukeParkerDev
2026-04-20 12:54:12 +10:00
parent babaf781da
commit 2e0b993d5d
9 changed files with 585 additions and 436 deletions
+39 -29
View File
@@ -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 }>) {
<UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</WslServersProvider>
</QueryProvider>
</ErrorBoundary>
</UiI18nBridge>
@@ -215,33 +219,37 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
)
return (
<Suspense fallback={splash}>
<Show
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
fallback={splash}
>
<Show when={server.ready()} fallback={splash}>
<Suspense fallback={splash}>
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
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}
<Show
when={startupHealthCheck()}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
void withServerSwitchOverlay(() =>
startTransition(() => {
batch(() => {
setCheckMode("blocking")
server.setActive(key)
})
}),
)
}}
/>
}
>
{props.children}
</Show>
</Show>
</Show>
</Suspense>
</Suspense>
</Show>
)
}
@@ -336,6 +344,7 @@ export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
servers?: Array<ServerConnection.Any>
serversReady?: boolean
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
}) {
@@ -349,6 +358,7 @@ export function AppInterface(props: {
<ServerProvider
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
serversReady={props.serversReady}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
@@ -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<typeof useLanguage>, 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<ServerConnection.Key, ServerHealth | undefined>,
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<void>((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 (
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
<div class="flex flex-col h-full items-start w-5">
@@ -793,12 +744,12 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<Button
variant="secondary"
size="small"
disabled={!!store.wslState?.job}
disabled={!!wslState()?.job}
class="shrink-0"
onPointerDown={(e: PointerEvent) => 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 = {}) {
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http" || isWslSidecar}>
<Show when={i.type === "http" || i.type === "sidecar"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
@@ -832,19 +783,19 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
</DropdownMenu.Item>
</Show>
<Show when={isWslSidecar && canRetryWsl(i)}>
<DropdownMenu.Item onSelect={() => void handleRetryWsl(i)}>
<DropdownMenu.Item onSelect={() => handleRetryWsl(i)}>
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={i.type === "http" && canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<Show when={canChangeDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => void setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={i.type === "http" && canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<Show when={canChangeDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => void setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
@@ -853,9 +804,15 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
<Show when={hasMenuActionsBeforeDelete()}>
<DropdownMenu.Separator />
</Show>
<Show when={i.type === "http" || isWslSidecar}>
<Show when={canRemove()}>
<DropdownMenu.Item
onSelect={() => (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"
>
<DropdownMenu.ItemLabel>
+237 -139
View File
@@ -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 (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show when={!store.loading} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
<div class="flex gap-2 pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
classList={{
"border-border-strong-base bg-surface-base-hover": item.state === "current",
"border-icon-success-base/40 bg-surface-base": item.state === "done",
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
}}
disabled={item.locked}
onClick={() => setStore("step", item.step)}
>
<div class="text-13-medium text-text-strong">{item.title}</div>
</button>
)}
</For>
</div>
<Show when={!wslServers.isPending} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
<Show when={!wslServers.isError} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}>
<div class="flex gap-2 pb-1">
<For each={steps()}>
{(item) => (
<button
type="button"
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
classList={{
"border-border-strong-base bg-surface-base-hover": item.state === "current",
"border-icon-success-base/40 bg-surface-base": item.state === "done",
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
}}
disabled={item.locked}
onClick={() => setStore("step", item.step)}
>
<div class="text-13-medium text-text-strong">{item.title}</div>
</button>
)}
</For>
</div>
<Switch>
<Switch>
<Match when={activeStep() === "wsl"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
@@ -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
</Button>
@@ -355,7 +406,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
<div class="rounded-md border border-border-weak-base px-3 py-3 flex items-center justify-between gap-3">
<div class="text-12-regular text-text-warning-base">Windows restart required.</div>
<Button variant="secondary" size="large" onClick={() => void platform.restart()}>
Restart OpenCode
Relaunch OpenCode
</Button>
</div>
</Show>
@@ -369,7 +420,23 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
<Match when={activeStep() === "distro"}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="text-14-medium text-text-strong">Choose a distro</div>
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">Choose a distro</div>
<Show when={selectedDistro()}>
<Button
variant="ghost"
size="small"
disabled={busy()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => probeDistroMutation.mutateAsync(distro))
}}
>
Refresh
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
<div class="flex flex-col gap-2">
@@ -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"}
</Button>
@@ -488,18 +555,32 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
</div>
</Show>
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => wslServers()!.openTerminal(distro))
}}
>
Open terminal
</Button>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => openTerminalMutation.mutateAsync(distro))
}}
>
Open terminal
</Button>
<Button
variant="ghost"
size="large"
disabled={busy() || !selectedDistro()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => probeDistroMutation.mutateAsync(distro))
}}
>
Refresh
</Button>
</div>
<div class="flex items-center justify-end">
<Button
@@ -518,20 +599,36 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="flex items-center justify-between gap-3">
<div class="text-14-medium text-text-strong">OpenCode</div>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => wslServers()!.installOpencode(distro))
}}
>
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
</Button>
</Show>
<div class="flex items-center gap-2">
<Show when={selectedDistro()}>
<Button
variant="ghost"
size="large"
disabled={busy()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => probeOpencodeMutation.mutateAsync(distro))
}}
>
Refresh
</Button>
</Show>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => {
const distro = selectedDistro()
if (!distro) return
void run(() => installOpencodeMutation.mutateAsync(distro))
}}
>
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
</Button>
</Show>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
<Show when={opencodeMismatchCheck()}>
@@ -552,58 +649,59 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
</Show>
</div>
</Match>
</Switch>
</Switch>
<Show when={installProgress()}>
{(progress) => (
<Show when={installProgress()}>
{(progress) => (
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
<div class="flex items-center gap-2 text-14-medium text-text-strong">
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
<div>Progress</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{progress().title}</div>
<div class="rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular whitespace-pre-wrap break-words">
<For
each={
progress().lines.length
? progress().lines
: [{ stream: "system" as const, text: "Waiting for output...", at: 0 }]
}
>
{(line) => (
<div
classList={{
"text-text-warning-base": line.stream === "stderr",
"text-text-weak": line.stream !== "stderr",
}}
>
{line.text}
</div>
)}
</For>
</div>
</div>
)}
</Show>
<Show when={current()?.lastError && (current()?.transcript.length ?? 0) > 0}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
<div class="flex items-center gap-2 text-14-medium text-text-strong">
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
<div>Progress</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{progress().title}</div>
<div class="rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular whitespace-pre-wrap break-words">
<For
each={
progress().lines.length
? progress().lines
: [{ stream: "system" as const, text: "Waiting for output...", at: 0 }]
}
>
{(line) => (
<div
classList={{
"text-text-warning-base": line.stream === "stderr",
"text-text-weak": line.stream !== "stderr",
}}
>
{line.text}
</div>
)}
</For>
<div class="text-14-medium text-text-strong">Diagnostics</div>
<div class="rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular text-text-weak whitespace-pre-wrap break-words">
<For each={current()?.transcript ?? []}>{(line) => <div>{line.text}</div>}</For>
</div>
</div>
)}
</Show>
</Show>
<Show when={current()?.lastError && (current()?.transcript.length ?? 0) > 0}>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
<div class="text-14-medium text-text-strong">Diagnostics</div>
<div class="rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular text-text-weak whitespace-pre-wrap break-words">
<For each={current()?.transcript ?? []}>{(line) => <div>{line.text}</div>}</For>
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" disabled={store.adding || busy()} onClick={() => void finish()}>
{store.adding ? "Adding..." : "Add WSL server"}
</Button>
</div>
</div>
</Show>
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" disabled={store.adding || busy()} onClick={() => void finish()}>
{store.adding ? "Adding..." : "Add WSL server"}
</Button>
</div>
</Show>
</Show>
</Show>
</div>
@@ -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<ServerConnection.Any[]>, enabled: Acc
return status
}
const useDefaultServerKey = (
get: (() => string | Promise<string | null | undefined> | 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<boolean> }) {
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<boolean> }) {
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<boolean> }) {
"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)
})
}),
)
}}
>
<ServerHealthIndicator health={health[key]} />
<ServerRow
conn={s}
@@ -322,7 +280,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
nameClass="text-14-regular text-text-base truncate"
versionClass="text-12-regular text-text-weak truncate"
badge={
<Show when={key === defaultServer.key()}>
<Show when={key === defaultServer.defaultKey()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
@@ -348,7 +306,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
if (dialogDead || dialogRun !== run) return
dialog.show(
() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />,
defaultServer.refresh,
() => void defaultServer.query.refetch(),
)
})
}}
@@ -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<typeof usePlatform>["getDefaultServer"]) {
return queryOptions<ServerConnection.Key | null>({
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)
},
}
}
+11 -5
View File
@@ -97,9 +97,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
init: (props: {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
serversReady?: boolean
servers?: Array<ServerConnection.Any>
}) => {
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<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const current: Accessor<ServerConnection.Any | undefined> = 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
+36
View File
@@ -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<WslServersState>({
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
},
})
+1
View File
@@ -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,
@@ -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<WslServersState | null>(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<WslServersState | undefined>()
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 = (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
)
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 <LocalServerStartupError message={startup.latest.error} />
}
return (
<AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make(startup.latest?.sidecar?.local.key ?? "local:windows")}
serversReady={wslReady()}
servers={servers()}
router={MemoryRouter}
>
<Inner />
</AppInterface>
)
}
onMount(() => {
document.addEventListener("click", handleClick)
onCleanup(() => {
@@ -526,25 +576,7 @@ render(() => {
return (
<PlatformProvider value={platform}>
<AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !startup.loading && !windowCount.loading && !locale.loading}>
{(_) => {
if (startup.latest?.error) {
return <LocalServerStartupError message={startup.latest.error} />
}
return (
<AppInterface
defaultServer={
defaultServer.latest ??
ServerConnection.Key.make(startup.latest?.sidecar?.local.key ?? "local:windows")
}
servers={servers()}
router={MemoryRouter}
>
<Inner />
</AppInterface>
)
}}
</Show>
<App />
</AppBaseProviders>
</PlatformProvider>
)