diff --git a/packages/app/src/components/dialog-local-server.tsx b/packages/app/src/components/dialog-local-server.tsx index 524fbfb06a..853f90661b 100644 --- a/packages/app/src/components/dialog-local-server.tsx +++ b/packages/app/src/components/dialog-local-server.tsx @@ -1,17 +1,20 @@ import { Button } from "@opencode-ai/ui/button" import { showToast } from "@opencode-ai/ui/toast" -import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js" import { createStore, reconcile, unwrap } from "solid-js/store" import { useLanguage } from "@/context/language" -import type { LocalServerConfig, LocalServerState } from "@/context/platform" +import type { LocalServerConfig, LocalServerState, LocalServerStep } from "@/context/platform" import { usePlatform } from "@/context/platform" +const STEP_ORDER: LocalServerStep[] = ["wsl", "distro", "opencode", "switch"] + export function DialogLocalServer() { const language = useLanguage() const platform = usePlatform() const [store, setStore] = createStore({ state: undefined as LocalServerState | undefined, loading: true, + step: undefined as LocalServerStep | undefined, }) createEffect(() => { @@ -43,7 +46,10 @@ export function DialogLocalServer() { const localServer = () => platform.localServer const busy = createMemo(() => !!current()?.job) const mode = createMemo(() => current()?.config.mode ?? "windows") - const selected = createMemo(() => current()?.checks.distro?.selected) + const selectedProbe = createMemo(() => current()?.checks.distro?.selected) + const selectedInstalled = createMemo(() => + (current()?.checks.distro?.installed ?? []).find((item) => item.name === current()?.config.distro), + ) const otherDistros = createMemo(() => (current()?.checks.distro?.online ?? []) .filter((item) => item.name !== "Debian" && item.name !== "Ubuntu-24.04") @@ -62,6 +68,33 @@ export function DialogLocalServer() { if (!state) return false return state.runtime.mode !== configuredRuntime().mode || state.runtime.distro !== configuredRuntime().distro }) + const wslReady = createMemo(() => !!current()?.checks.wsl?.available && !current()?.config.onboarding.pendingRestart) + const distroReady = createMemo(() => { + const probe = selectedProbe() + if (!probe || !current()?.config.distro) return false + if (selectedInstalled()?.version === 1) return false + return probe.canExecute && probe.hasBash && probe.hasCurl + }) + const opencodeReady = createMemo(() => !!current()?.checks.opencode?.resolvedPath) + const switchReady = createMemo(() => wslReady() && distroReady() && opencodeReady()) + const recommendedStep = createMemo(() => { + if (!wslReady()) return "wsl" + if (!distroReady()) return "distro" + if (!opencodeReady()) return "opencode" + return "switch" + }) + const activeStep = createMemo(() => store.step ?? current()?.job?.step ?? recommendedStep()) + + createEffect(() => { + if (mode() !== "wsl") { + if (store.step) setStore("step", undefined) + return + } + const next = current()?.job?.step ?? recommendedStep() + if (!store.step || stepIndex(store.step) > stepIndex(next)) { + setStore("step", next) + } + }) const run = async (action: () => Promise) => { try { @@ -77,6 +110,7 @@ export function DialogLocalServer() { const state = current() if (!state || !localServer()) return const config = plainConfig(state.config) + if (next === "wsl") setStore("step", "wsl") await run(() => localServer()!.setConfig({ ...config, @@ -85,7 +119,7 @@ export function DialogLocalServer() { ...config.onboarding, complete: next === "windows", pendingRestart: next === "windows" ? false : config.onboarding.pendingRestart, - step: next === "windows" ? null : config.onboarding.step, + step: next === "windows" ? null : (config.onboarding.step ?? "wsl"), }, }), ) @@ -95,6 +129,7 @@ export function DialogLocalServer() { const state = current() if (!state || !localServer()) return const config = plainConfig(state.config) + setStore("step", "distro") await run(() => localServer()!.setConfig({ ...config, @@ -109,6 +144,33 @@ export function DialogLocalServer() { ) } + const steps = createMemo(() => + STEP_ORDER.map((step) => ({ + step, + title: stepTitle(step), + subtitle: stepSubtitle(step, { + current: current(), + selectedInstalled: selectedInstalled(), + selectedProbe: selectedProbe(), + wslReady: wslReady(), + distroReady: distroReady(), + opencodeReady: opencodeReady(), + switchReady: switchReady(), + needsRestart: needsRestart(), + }), + locked: stepIndex(step) > stepIndex(recommendedStep()), + state: stepState(step, { + active: activeStep(), + current: current(), + wslReady: wslReady(), + distroReady: distroReady(), + opencodeReady: opencodeReady(), + switchReady: switchReady(), + needsRestart: needsRestart(), + }), + })), + ) + return (
Loading local server...
} >
-
Runtime
-
- - +
+
+
Local runtime
+
Choose where the managed Local Server should run.
+
+
+ + +
Current runtime:{" "} - {current()?.runtime.mode === "wsl" - ? `wsl${current()?.runtime.distro ? `:${current()?.runtime.distro}` : ""}` - : "windows"} + {current()?.runtime.mode === "wsl" ? `wsl:${current()?.runtime.distro ?? "unknown"}` : "windows"}
- -
-
Restart OpenCode to apply local runtime changes.
- + +
+ Select Run in WSL to start the WSL setup flow.
-
-
-
WSL
-
- {current()?.checks.wsl?.error ?? - current()?.checks.wsl?.status ?? - current()?.checks.wsl?.version ?? - "Not checked yet"} -
-
-
- - -
+
Setup flow
+
+ + {(item) => ( + + )} +
- -
- Windows restart required to finish WSL installation. -
-
-
-
-
-
Distro
-
- {current()?.checks.distro?.error ?? - selected()?.name ?? - current()?.config.distro ?? - "No distro selected"} -
-
-
- -
-
- -
- - -
- - 0}> -
-
Other distros
-
- - {(item) => ( - - )} - -
-
-
- -
-
Installed distros
- 0} - fallback={
No distros detected yet.
} - > - - {(item) => ( - + +
+
+ +
+
+ Windows restart required to finish WSL installation. +
+ +
+
+
+ + + +
+
+
+
Step 2: Choose a distro
+
+ {current()?.checks.distro?.error ?? + current()?.config.distro ?? + "Pick a distro or install one below."} +
+
+ +
+ +
+ + +
+ + 0}> +
+
Other distros
+
+ + {(item) => ( + + )} + +
+
+
+ +
+
Installed distros
+ 0} + fallback={
No distros detected yet.
} + > + + {(item) => ( + + )} + +
+
+ + + {(probe) => ( +
+
Selected distro checks
- {[item.isDefault ? "default" : null, item.state, item.version ? `WSL ${item.version}` : null] - .filter(Boolean) - .join(" · ")} + User: {probe().username ?? "unknown"} + {probe().isRoot ? " · root" : ""} + {selectedInstalled()?.version === 1 ? " · WSL 1" : ""}
- +
+ bash: {probe().hasBash ? "yes" : "no"} · curl: {probe().hasCurl ? "yes" : "no"} · exec:{" "} + {probe().canExecute ? "yes" : "no"} +
+ +
+ WSL 2 is required. Convert this distro before continuing. +
+
+
)} - -
-
+
- - {(probe) => ( -
-
Selected distro checks
-
- User: {probe().username ?? "unknown"} - {probe().isRoot ? " · root" : ""} -
-
- bash: {probe().hasBash ? "yes" : "no"} · curl: {probe().hasCurl ? "yes" : "no"} · exec:{" "} - {probe().canExecute ? "yes" : "no"} -
-
- )} -
- -
- -
-
- - -
-
-
-
OpenCode
-
- {current()?.checks.opencode?.error ?? current()?.checks.opencode?.resolvedPath ?? "Not checked yet"} -
-
-
+ - - {(check) => ( -
-
Path: {check().resolvedPath ?? "not found"}
-
- Version: {check().version ?? "unknown"} - - {(expected) => {` · desktop ${expected()}`}} + +
+
+
+
Step 3: Install OpenCode
+
+ {current()?.checks.opencode?.error ?? + current()?.checks.opencode?.resolvedPath ?? + "OpenCode has not been checked in this distro yet."} +
+
+
+ + +
+
+ + + {(check) => ( +
+
Path: {check().resolvedPath ?? "not found"}
+
+ Version: {check().version ?? "unknown"} + + {(expected) => {` · desktop ${expected()}`}} + +
+ +
+ Installed version does not match the desktop app version. +
- -
- Installed version does not match the desktop app version. -
-
+ )} +
+
+
+ + +
+
+
+
Step 4: Switch Local Server
+
+ {needsRestart() + ? "Restart OpenCode to apply your WSL local runtime configuration." + : "WSL local runtime is configured and active."} +
- )} - -
- + +
+ +
+
+ Configured runtime:{" "} + {configuredRuntime().mode === "wsl" ? `wsl:${configuredRuntime().distro ?? "unknown"}` : "windows"} +
+
+ Current runtime:{" "} + {current()?.runtime.mode === "wsl" ? `wsl:${current()?.runtime.distro ?? "unknown"}` : "windows"} +
+ +
+ Complete the earlier setup steps before switching. +
+
+
+
+ + 0}> @@ -375,3 +524,72 @@ function requestError(language: ReturnType, err: unknown) { description: err instanceof Error ? err.message : String(err), }) } + +function stepIndex(step: LocalServerStep) { + return STEP_ORDER.indexOf(step) +} + +function stepNumber(step: LocalServerStep) { + return `${stepIndex(step) + 1}` +} + +function stepTitle(step: LocalServerStep) { + if (step === "wsl") return "WSL" + if (step === "distro") return "Distro" + if (step === "opencode") return "OpenCode" + return "Switch" +} + +function stepSubtitle( + step: LocalServerStep, + state: { + current?: LocalServerState + selectedInstalled?: LocalServerState["checks"]["distro"] extends infer T ? any : never + selectedProbe?: LocalServerState["checks"]["distro"] extends infer T ? any : never + wslReady: boolean + distroReady: boolean + opencodeReady: boolean + switchReady: boolean + needsRestart: boolean + }, +) { + if (step === "wsl") { + if (state.wslReady) return "Ready" + return state.current?.checks.wsl?.error ?? "Install or verify WSL" + } + if (step === "distro") { + if (state.distroReady) return state.current?.config.distro ?? "Ready" + if (state.selectedInstalled?.version === 1) return "Convert to WSL 2" + return state.current?.checks.distro?.error ?? state.current?.config.distro ?? "Choose a distro" + } + if (step === "opencode") { + if (state.opencodeReady) return state.current?.checks.opencode?.version ?? "Ready" + return state.current?.checks.opencode?.error ?? "Install OpenCode" + } + if (!state.switchReady) return "Complete prior steps" + return state.needsRestart ? "Restart to apply" : "Active" +} + +function stepState( + step: LocalServerStep, + state: { + active: LocalServerStep + current?: LocalServerState + wslReady: boolean + distroReady: boolean + opencodeReady: boolean + switchReady: boolean + needsRestart: boolean + }, +) { + if (state.current?.job?.step === step) return "current" + if (state.active === step) return "current" + if (step === "wsl") return state.wslReady ? "done" : "warning" + if (step === "distro") + return state.distroReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning" + if (step === "opencode") + return state.opencodeReady ? "done" : stepIndex(step) > stepIndex(state.active) ? "locked" : "warning" + if (state.switchReady && !state.needsRestart) return "done" + if (stepIndex(step) > stepIndex(state.active)) return "locked" + return "warning" +} diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index caefa2cf94..3d375c34a7 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -479,6 +479,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { setStore("localServer", "showPage", true) } + const localSwapLabel = (conn: ServerConnection.Any) => { + if (conn.type !== "sidecar") return "" + return conn.variant === "wsl" ? "Swap to Windows" : "Swap to WSL" + } + const submitForm = () => { if (mode() === "add") { if (addMutation.isPending) return @@ -558,114 +563,111 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) { } > -
- -
- -
-
- - x.http.url} - onSelect={(x) => { - if (x) void select(x) - }} - divider={true} - class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" - > - {(i) => { - const key = ServerConnection.key(i) - return ( -
-
- -
- - - {language.t("dialog.server.status.default")} - - - } - showCredentials - /> -
- - + x.http.url} + onSelect={(x) => { + if (x) void select(x) + }} + divider={true} + class="px-5 [&_[data-slot=list-search-wrapper]]:w-full [&_[data-slot=list-scroll]]h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:min-h-14 [&_[data-slot=list-item]]:p-3 [&_[data-slot=list-item]]:!bg-transparent" + > + {(i) => { + const key = ServerConnection.key(i) + return ( +
+
+ +
+ + + {language.t("dialog.server.status.default")} + + } + showCredentials + /> +
+ + + - - - e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - - - { - if (i.type !== "http") return - startEdit(i) - }} - > - {language.t("dialog.server.menu.edit")} - - - setDefault(key)}> - - {language.t("dialog.server.menu.default")} - - - - - setDefault(null)}> - - {language.t("dialog.server.menu.defaultRemove")} - - - - - handleRemove(ServerConnection.key(i))} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > + + + + + + + e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + + + { + if (i.type !== "http") return + startEdit(i) + }} + > + {language.t("dialog.server.menu.edit")} + + + setDefault(key)}> - {language.t("dialog.server.menu.delete")} + {language.t("dialog.server.menu.default")} - - - - -
+ + + setDefault(null)}> + + {language.t("dialog.server.menu.defaultRemove")} + + + + + handleRemove(ServerConnection.key(i))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > + {language.t("dialog.server.menu.delete")} + + + + +
- ) - }} -
-
+
+ ) + }} +