feat: desktop v2 everything WSL (#23407)

This commit is contained in:
Luke Parker
2026-06-06 14:38:06 +10:00
committed by GitHub
parent 09d9cf01f9
commit bd7eb0603f
37 changed files with 2620 additions and 202 deletions
+36
View File
@@ -0,0 +1,36 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, onCleanup } from "solid-js"
import type { WslServersState } from "./types"
import { usePlatform } from "../context/platform"
const wslServersQueryKey = ["platform", "wslServers"] as const
export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
name: "WslServers",
init: () => {
const platform = usePlatform()
const queryClient = useQueryClient()
const query = useQuery(() => {
const api = platform.wslServers
return queryOptions<WslServersState>({
queryKey: wslServersQueryKey,
queryFn: () => api!.getState(),
enabled: !!api,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
})
})
createEffect(() => {
const api = platform.wslServers
if (!api) return
const off = api.subscribe((event) => {
queryClient.setQueryData(wslServersQueryKey, event.state)
})
onCleanup(off)
})
return query as typeof query & { readonly data: WslServersState | undefined }
},
})
+623
View File
@@ -0,0 +1,623 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useWslServers } from "./context"
import { enterWslOpencodeStep } from "./settings-model"
type WslServerStep = "wsl" | "distro" | "opencode"
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
}
interface DialogWslServerProps {
onAdded?: (distro: string) => void | Promise<void>
}
export function DialogAddWslServer(props: DialogWslServerProps = {}) {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const wslServers = useWslServers()
const api = platform.wslServers!
const [store, setStore] = createStore({
step: undefined as WslServerStep | undefined,
selectedDistro: null as string | null,
installTarget: undefined as string | undefined,
adding: false,
})
const current = () => wslServers.data
let disposed = false
onCleanup(() => {
disposed = true
})
const busy = createMemo(() => !!current()?.job || store.adding)
const visibleInstalledDistros = createMemo(() =>
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
)
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
const addableInstalledDistros = createMemo(() => {
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
})
const selectedDistro = createMemo(() => {
if (store.selectedDistro && addableInstalledDistros().some((item) => item.name === store.selectedDistro)) {
return store.selectedDistro
}
const distro = defaultInstalledDistro()
if (distro && !existingServerDistros().has(distro.name)) return distro.name
return null
})
const selectedProbe = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return current()?.distroProbes[distro] ?? null
})
const selectedInstalled = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
})
const opencodeCheck = createMemo(() => {
const distro = selectedDistro()
if (!distro) return null
return current()?.opencodeChecks[distro] ?? null
})
const distroWarningProbe = createMemo(() => {
const probe = selectedProbe()
if (!probe) return null
if (distroReady()) return null
return probe
})
const distroUnavailableMessage = createMemo(() => {
const probe = distroWarningProbe()
const distro = selectedDistro()
if (!probe || probe.canExecute || !distro) return null
if (!selectedInstalled()) return language.t("wsl.onboarding.distroNotInstalled", { distro })
return language.t("wsl.onboarding.openDistroOnce", { distro })
})
const distroMissingTools = createMemo(() => {
const probe = distroWarningProbe()
if (!probe?.canExecute) return null
if (probe.hasBash && probe.hasCurl) return null
return probe
})
const installableDistros = createMemo(() => {
const online = visibleOnlineDistros()
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
return online
.filter((item) => !installed.has(item.name))
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
})
const installTarget = createMemo(
() => installableDistros().find((item) => item.name === store.installTarget) ?? installableDistros()[0] ?? null,
)
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
const installingOpencode = createMemo(() => {
const job = current()?.job
return job?.kind === "install-opencode" && job.distro === selectedDistro()
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
if (!probe || !selectedDistro()) return false
if (selectedInstalled()?.version === 1) return false
return probe.canExecute && probe.hasBash && probe.hasCurl
})
const opencodeReady = createMemo(() => {
const check = opencodeCheck()
return !!check?.resolvedPath && !check.error
})
const allReady = createMemo(() => wslReady() && distroReady() && opencodeReady())
const addDisabled = createMemo(() => {
const job = current()?.job
if (!job) return store.adding
return store.adding || job.kind !== "probe-opencode"
})
const recommendedStep = createMemo<WslServerStep>(() => {
if (!wslReady()) return "wsl"
if (!distroReady()) return "distro"
return "opencode"
})
// activeStep falls back to recommendedStep when the user hasn't picked one.
// Once the user clicks a step tab we respect their choice rather than snapping
// them back when a probe result updates recommendedStep.
const activeStep = createMemo(() => store.step ?? recommendedStep())
const autoProbe = createMemo(() => {
const state = current()
if (!state || busy()) return null
if (state.pendingRestart) return null
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
if (!wslReady()) return null
if (!state.installed.length && !state.online.length) {
return { key: "distros", run: () => api.refreshDistros() }
}
const distro = selectedDistro()
if (distro && !state.distroProbes[distro]) {
return { key: `probe-distro:${distro}`, run: () => api.probeDistro(distro) }
}
if (!distro || !distroReady()) return null
if (!state.opencodeChecks[distro]) {
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
}
return null
})
let lastAutoProbe: string | null = null
createEffect(() => {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
const key = probe.key
lastAutoProbe = key
void (async () => {
try {
await probe.run()
} catch (err) {
if (disposed) return
// Allow the same probe to run again when reactive inputs next change
// (e.g. user reselects a distro). Without this the user would be stuck
// on a transient wsl.exe failure until they pick a different distro.
if (lastAutoProbe === key) lastAutoProbe = null
requestError(language, err)
}
})()
})
const wslMessage = createMemo(() => {
const state = current()
if (!state || state.job?.kind === "runtime") return language.t("wsl.onboarding.checkingRuntime")
if (state.pendingRestart) return language.t("wsl.onboarding.restartRequired")
if (state.runtime?.available) return state.runtime.version ?? language.t("wsl.onboarding.ready")
return state.runtime?.error ?? language.t("wsl.onboarding.required")
})
const distroMessage = createMemo(() => {
const state = current()
if (!state) return language.t("wsl.onboarding.checkingDistros")
const distro = selectedDistro()
if (state.job?.kind === "install-distro")
return language.t("wsl.onboarding.installingDistro", { distro: state.job.distro })
if (state.job?.kind === "probe-distro")
return language.t("wsl.onboarding.checkingDistro", { distro: state.job.distro })
if (state.job?.kind === "distros") return language.t("wsl.onboarding.listingDistros")
if (distroUnavailableMessage()) return distroUnavailableMessage()!
if (selectedProbe() && distroReady())
return language.t("wsl.onboarding.distroReady", { distro: selectedProbe()!.name })
if (distro) return language.t("wsl.onboarding.finishingDistro", { distro })
return language.t("wsl.onboarding.pickDistro")
})
const opencodeMessage = createMemo(() => {
const state = current()
if (!state) return language.t("wsl.onboarding.checkingOpencode")
const distro = selectedDistro()
if (state.job?.kind === "install-opencode") {
return distro
? language.t("wsl.onboarding.updatingOpencodeIn", { distro })
: language.t("wsl.onboarding.updatingOpencode")
}
if (state.job?.kind === "probe-opencode") {
return distro
? language.t("wsl.onboarding.checkingOpencodeIn", { distro })
: language.t("wsl.onboarding.checkingOpencode")
}
if (opencodeCheck()?.error) return opencodeCheck()!.error
if (opencodeCheck()?.matchesDesktop === false) {
return distro
? language.t("wsl.onboarding.updateOpencodeIn", { distro })
: language.t("wsl.onboarding.updateOpencode")
}
if (opencodeReady()) {
return distro
? language.t("wsl.onboarding.opencodeReadyIn", { distro })
: language.t("wsl.onboarding.opencodeReady")
}
return distro
? language.t("wsl.onboarding.installOpencodeIn", { distro })
: language.t("wsl.onboarding.chooseDistroFirst")
})
const run = async (action: () => Promise<unknown>) => {
try {
await action()
} catch (err) {
requestError(language, err)
}
}
const runSelectedDistro = (action: (distro: string) => Promise<unknown>) => {
const distro = selectedDistro()
if (!distro) return
void run(() => action(distro))
}
const selectDistro = (name: string) => {
setStore("selectedDistro", name)
setStore("step", undefined)
}
const openOpencodeStep = () => {
const distro = selectedDistro()
if (!distro) return
void run(() => enterWslOpencodeStep(distro, api.probeOpencode, (step) => setStore("step", step)))
}
const finish = async () => {
const distro = selectedDistro()
if (!distro) return
setStore("adding", true)
try {
await api.addServer(distro)
if (props.onAdded) {
await props.onAdded(distro)
} else {
dialog.close()
}
} catch (err) {
requestError(language, err)
} finally {
setStore("adding", false)
}
}
const steps = createMemo(() => {
const active = activeStep()
const activeIndex = STEPS.indexOf(active)
const recommendedIndex = STEPS.indexOf(recommendedStep())
return STEPS.map((step) => {
const index = STEPS.indexOf(step)
return {
step,
title:
step === "wsl"
? language.t("wsl.server.label")
: step === "distro"
? language.t("wsl.onboarding.step.distro")
: language.t("wsl.onboarding.step.opencode"),
state:
active === step
? "current"
: step === "wsl"
? wslReady()
? "done"
: "warning"
: step === "distro"
? distroReady()
? "done"
: index > activeIndex
? "locked"
: "warning"
: opencodeCheck()?.matchesDesktop === false
? "warning"
: opencodeReady()
? "done"
: index > activeIndex
? "locked"
: "warning",
locked: index > recommendedIndex,
}
})
})
const loadError = createMemo(() => {
const error = wslServers.error
if (!error) return language.t("wsl.onboarding.loadFailed")
return error instanceof Error ? error.message : String(error)
})
return (
<div class="px-5 pb-5 flex flex-col gap-4">
<Show
when={!wslServers.isPending}
fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{language.t("wsl.onboarding.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>
<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">
<div class="text-14-medium text-text-strong">{language.t("wsl.server.label")}</div>
<Show when={current()?.runtime && !wslReady() && !current()?.pendingRestart}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => api.installWsl())}
>
{language.t("wsl.onboarding.installWsl")}
</Button>
</Show>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{wslMessage()}</div>
<Show when={current()?.pendingRestart}>
<div class="rounded-md border border-border-weak-base px-3 py-3">
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.windowsRestartRequired")}
</div>
</div>
</Show>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !wslReady()}
onClick={() => setStore("step", "distro")}
>
{language.t("wsl.onboarding.next")}
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "distro"}>
<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">{language.t("wsl.onboarding.step.distro")}</div>
<Show when={selectedDistro()}>
<Button
variant="ghost"
size="small"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
{language.t("wsl.onboarding.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">
<Show
when={addableInstalledDistros().length > 0}
fallback={
<div class="text-12-regular text-text-weak">
{visibleInstalledDistros().length
? language.t("wsl.onboarding.allDistrosAdded")
: current()?.runtime?.available
? language.t("wsl.onboarding.noDistros")
: language.t("wsl.onboarding.checkingDistros")}
</div>
}
>
<For each={addableInstalledDistros()}>
{(item) => (
<button
type="button"
class="rounded-md border border-border-weak-base px-3 py-2 text-left transition-colors"
classList={{ "bg-surface-raised-base": selectedDistro() === item.name }}
onClick={() => selectDistro(item.name)}
>
<div class="text-13-medium text-text-strong">{item.name}</div>
<Show when={item.isDefault}>
<div class="text-12-regular text-text-weak">{language.t("common.default")}</div>
</Show>
</button>
)}
</For>
</Show>
</div>
<Show when={installableDistros().length > 0}>
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">{language.t("wsl.onboarding.install")}</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={installingDistro()}>
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
</Show>
<Button
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
>
{installingDistro()
? language.t("wsl.onboarding.installing")
: language.t("wsl.onboarding.install")}
</Button>
</div>
</div>
<div
role="radiogroup"
aria-label={language.t("wsl.onboarding.installDistro")}
class="max-h-52 overflow-y-auto rounded-md bg-background-base"
>
<For each={installableDistros()}>
{(item) => {
const selected = () => installTarget()?.name === item.name
return (
<button
type="button"
role="radio"
aria-checked={selected()}
disabled={busy()}
class="w-full px-3 py-2 flex items-center gap-3 text-left border-b border-border-weak-base last:border-b-0 transition-colors"
classList={{
"bg-surface-raised-base": selected(),
"hover:bg-surface-base": !selected(),
}}
onClick={() => setStore("installTarget", item.name)}
>
<div
class="mt-0.5 h-4 w-4 rounded-full border border-border-strong-base flex items-center justify-center shrink-0"
classList={{ "border-text-strong": selected() }}
>
<div class="h-2 w-2 rounded-full bg-text-strong" classList={{ hidden: !selected() }} />
</div>
<div class="min-w-0 flex-1 text-13-medium text-text-strong truncate">{item.label}</div>
</button>
)
}}
</For>
</div>
</div>
</Show>
<Show when={selectedInstalled()?.version === 1 || distroUnavailableMessage() || distroMissingTools()}>
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<Show when={selectedInstalled()?.version === 1}>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.wsl2Required")}
</div>
</Show>
<Show when={distroUnavailableMessage()}>
{(message) => <div class="text-12-regular text-text-warning-base">{message()}</div>}
</Show>
<Show when={distroMissingTools()}>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.toolsRequired")}
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-2">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
>
{language.t("wsl.onboarding.openTerminal")}
</Button>
<Button
variant="ghost"
size="large"
disabled={busy() || !selectedDistro()}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</div>
<div class="flex items-center justify-end">
<Button
variant="secondary"
size="large"
disabled={busy() || !selectedDistro() || !distroReady()}
onClick={openOpencodeStep}
>
{language.t("wsl.onboarding.next")}
</Button>
</div>
</div>
</Match>
<Match when={activeStep() === "opencode"}>
<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">{language.t("wsl.onboarding.step.opencode")}</div>
<div class="flex items-center gap-2">
<Show when={selectedDistro()}>
<Button
variant="ghost"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
>
{language.t("wsl.onboarding.refresh")}
</Button>
</Show>
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
>
<Show when={installingOpencode()}>
<Spinner class="size-4 shrink-0" />
</Show>
{opencodeCheck()?.resolvedPath
? language.t("wsl.onboarding.updateOpencode")
: language.t("wsl.onboarding.installOpencode")}
</Button>
</Show>
</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
<Show when={opencodeCheck()?.matchesDesktop === false ? opencodeCheck() : null}>
{(check) => (
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<div class="text-12-regular text-text-weak">
{language.t("wsl.onboarding.path", {
path: check().resolvedPath ?? language.t("wsl.onboarding.notFound"),
})}
</div>
<div class="text-12-regular text-text-weak">
{language.t("wsl.onboarding.version", {
version: check().version ?? language.t("wsl.onboarding.unknown"),
})}
<Show when={check().expectedVersion}>
{(expected) => (
<span>{` · ${language.t("wsl.onboarding.desktopVersion", { version: expected() })}`}</span>
)}
</Show>
</div>
<div class="text-12-regular text-text-warning-base">
{language.t("wsl.onboarding.versionMismatch")}
</div>
</div>
)}
</Show>
</div>
</Match>
</Switch>
<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()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" disabled={addDisabled()} onClick={() => void finish()}>
{store.adding ? language.t("wsl.onboarding.adding") : language.t("wsl.server.add")}
</Button>
</div>
</Show>
</Show>
</Show>
</div>
)
}
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
console.error("WSL servers request failed", err instanceof Error ? (err.stack ?? err.message) : String(err))
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
@@ -0,0 +1,57 @@
import { describe, expect, test } from "bun:test"
import { enterWslOpencodeStep, wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
describe("WSL server settings presentation", () => {
test("retries only settled unsuccessful runtimes", () => {
expect(wslRuntimeRetryable({ kind: "starting" })).toBe(false)
expect(wslRuntimeRetryable({ kind: "ready", url: "http://127.0.0.1:4096", username: null, password: null })).toBe(
false,
)
expect(wslRuntimeRetryable({ kind: "failed", message: "boom" })).toBe(true)
expect(wslRuntimeRetryable({ kind: "stopped" })).toBe(true)
})
test("offers install and update only when OpenCode needs attention", () => {
expect(wslOpencodeAction(undefined)).toBeUndefined()
expect(
wslOpencodeAction({
distro: "Debian",
resolvedPath: null,
version: null,
expectedVersion: "1.2.3",
matchesDesktop: null,
error: null,
}),
).toBe("Install OpenCode")
expect(
wslOpencodeAction({
distro: "Debian",
resolvedPath: "/usr/local/bin/opencode",
version: "1.2.2",
expectedVersion: "1.2.3",
matchesDesktop: false,
error: null,
}),
).toBe("Update OpenCode")
expect(
wslOpencodeAction({
distro: "Debian",
resolvedPath: "/usr/local/bin/opencode",
version: "1.2.3",
expectedVersion: "1.2.3",
matchesDesktop: true,
error: null,
}),
).toBeUndefined()
})
test("probes the selected distro before entering the OpenCode step", async () => {
const calls: string[] = []
await enterWslOpencodeStep(
"Debian",
async (distro) => calls.push(distro),
(step) => calls.push(step),
)
expect(calls).toEqual(["Debian", "opencode"])
})
})
+19
View File
@@ -0,0 +1,19 @@
import type { WslOpencodeCheck, WslServerRuntime } from "./types"
export const wslRuntimeRetryable = (runtime: WslServerRuntime) =>
runtime.kind === "failed" || runtime.kind === "stopped"
export async function enterWslOpencodeStep(
distro: string,
probe: (distro: string) => Promise<unknown>,
select: (step: "opencode") => void,
) {
await probe(distro)
select("opencode")
}
export function wslOpencodeAction(check?: WslOpencodeCheck) {
if (!check) return
if (!check.resolvedPath) return "Install OpenCode"
if (check.matchesDesktop === false) return "Update OpenCode"
}
+167
View File
@@ -0,0 +1,167 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { useMutation } from "@tanstack/solid-query"
import fuzzysort from "fuzzysort"
import { type Accessor, For, Show, createMemo } from "solid-js"
import type { useServerManagementController } from "@/components/dialog-select-server"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { showToast } from "@/utils/toast"
import { DialogAddWslServer } from "./dialog-add-server"
import { useWslServers } from "./context"
import { wslOpencodeAction, wslRuntimeRetryable } from "./settings-model"
type Controller = ReturnType<typeof useServerManagementController>
export function isWslServer(server: ServerConnection.Any) {
return server.type === "sidecar" && server.variant === "wsl"
}
export function WslAddServerButton() {
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const openAdd = () => {
dialog.push(() => (
<Dialog title={language.t("wsl.server.add")} size="large" fit class="settings-v2-wsl-dialog">
<DialogAddWslServer />
</Dialog>
))
}
return (
<Show when={platform.wslServers}>
<ButtonV2 variant="ghost-muted" icon="plus" onClick={openAdd}>
{language.t("wsl.server.addShort")}
</ButtonV2>
</Show>
)
}
export function useFilteredWslServers(filter: Accessor<string>) {
const wsl = useWslServers()
return createMemo(() => {
const servers = wsl.data?.servers ?? []
const query = filter().trim()
if (!query) return servers
return fuzzysort
.go(query, servers, { keys: [(item) => item.config.distro, (item) => item.config.id] })
.map((x) => x.obj)
})
}
export function WslServerSettings(props: {
controller: Controller
servers: ReturnType<typeof useFilteredWslServers>
}) {
const platform = usePlatform()
const language = useLanguage()
const wsl = useWslServers()
const api = platform.wslServers
const request = useMutation(() => ({
mutationFn: (action: () => Promise<unknown>) => action(),
onError: (error) =>
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
}),
}))
const remove = (key: ServerConnection.Key) => {
if (!api) return
request.mutate(async () => {
await api.removeServer(key)
await props.controller.handleRemove(key)
})
}
return (
<Show when={api}>
<For each={props.servers()}>
{(item) => {
const key = ServerConnection.Key.make(item.config.id)
const check = () => wsl.data?.opencodeChecks[item.config.distro]
const opencodeAction = () => wslOpencodeAction(check())
const busy = () => wsl.data?.job?.kind === "install-opencode" && wsl.data.job.distro === item.config.distro
return (
<div class="settings-v2-servers-row">
<div class="settings-v2-servers-lead">
<ServerHealthIndicator health={props.controller.status()[key]} />
<div class="settings-v2-servers-copy">
<span class="flex min-w-0 items-center gap-1">
<span class="settings-v2-servers-name">{item.config.distro}</span>
<span class="shrink-0 rounded-[3px] border border-v2-border-border-base px-1 py-0.5 text-[9px] leading-none text-v2-text-text-muted">
{language.t("wsl.server.label")}
</span>
</span>
<span class="settings-v2-servers-meta">
<Show when={check()?.version}>{(version) => `v${version()}`}</Show>
</span>
</div>
</div>
<div class="settings-v2-servers-actions">
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<Tag>{language.t("dialog.server.status.default")}</Tag>
</Show>
<Show when={opencodeAction()}>
{(label) => (
<ButtonV2
size="small"
disabled={busy() || request.isPending}
onClick={() => api && request.mutate(() => api.installOpencode(item.config.distro))}
>
{busy() ? language.t("wsl.server.updating") : label()}
</ButtonV2>
)}
</Show>
<MenuV2 gutter={4} modal={false} placement="bottom-end">
<MenuV2.Trigger
as={IconButtonV2}
variant="ghost-muted"
size="small"
icon={<IconV2 name="outline-dots" />}
aria-label={language.t("common.moreOptions")}
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Group>
<MenuV2.GroupLabel>{language.t("wsl.server.menu.label")}</MenuV2.GroupLabel>
<Show when={wslRuntimeRetryable(item.runtime)}>
<MenuV2.Item onSelect={() => api && request.mutate(() => api.startServer(key))}>
{language.t("wsl.server.retryStart")}
</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() !== key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
</MenuV2.Item>
</Show>
<Show when={props.controller.canDefault() && props.controller.defaultKey() === key}>
<MenuV2.Item onSelect={() => props.controller.setDefault(null)}>
{language.t("dialog.server.menu.defaultRemove")}
</MenuV2.Item>
</Show>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => remove(key)}>
{language.t("dialog.server.menu.delete")}
</MenuV2.Item>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
</div>
</div>
)
}}
</For>
</Show>
)
}
+87
View File
@@ -0,0 +1,87 @@
export type WslRuntimeCheck = {
available: boolean
version: string | null
error: string | null
}
export type WslInstalledDistro = {
name: string
version: number | null
isDefault: boolean
}
export type WslOnlineDistro = {
name: string
label: string
}
export type WslDistroProbe = {
name: string
canExecute: boolean
hasBash: boolean
hasCurl: boolean
error: string | null
}
export type WslOpencodeCheck = {
distro: string
resolvedPath: string | null
version: string | null
expectedVersion: string | null
matchesDesktop: boolean | null
error: string | null
}
export type WslServerConfig = {
id: string
distro: string
}
export type WslServerRuntime =
| { kind: "starting" }
| { kind: "ready"; url: string; username: string | null; password: string | null }
| { kind: "failed"; message: string }
| { kind: "stopped" }
export type WslServerItem = {
config: WslServerConfig
runtime: WslServerRuntime
}
export type WslJob =
| { kind: "runtime"; startedAt: number }
| { kind: "distros"; startedAt: number }
| { kind: "install-wsl"; startedAt: number }
| { kind: "install-distro"; distro: string; startedAt: number }
| { kind: "probe-distro"; distro: string; startedAt: number }
| { kind: "probe-opencode"; distro: string; startedAt: number }
| { kind: "install-opencode"; distro: string; startedAt: number }
export type WslServersState = {
runtime: WslRuntimeCheck | null
installed: WslInstalledDistro[]
online: WslOnlineDistro[]
distroProbes: Record<string, WslDistroProbe>
opencodeChecks: Record<string, WslOpencodeCheck>
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }
export type WslServersPlatform = {
getState(): Promise<WslServersState>
subscribe(cb: (event: WslServersEvent) => void): () => void
probeRuntime(): Promise<void>
refreshDistros(): Promise<void>
installWsl(): Promise<void>
installDistro(name: string): Promise<void>
probeDistro(name: string): Promise<void>
probeOpencode(name: string): Promise<void>
installOpencode(name: string): Promise<void>
openTerminal(name: string): Promise<void>
addServer(distro: string): Promise<WslServerConfig>
removeServer(id: string): Promise<void>
startServer(id: string): Promise<void>
}