feat: add local server management dialog shell

This commit is contained in:
LukeParkerDev 2026-04-16 14:40:43 +10:00
parent df635562e9
commit 8d8e8fe8f4
2 changed files with 437 additions and 114 deletions

View file

@ -0,0 +1,276 @@
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@opencode-ai/ui/toast"
import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { LocalServerState } from "@/context/platform"
import { usePlatform } from "@/context/platform"
export function DialogLocalServer() {
const language = useLanguage()
const platform = usePlatform()
const [store, setStore] = createStore({
state: undefined as LocalServerState | undefined,
loading: true,
})
createEffect(() => {
const localServer = platform.localServer
if (!localServer) return
let mounted = true
void localServer
.getState()
.then((state) => {
if (!mounted) return
setStore({ state, loading: false })
})
.catch((err) => {
if (!mounted) return
requestError(language, err)
setStore("loading", false)
})
const off = localServer.subscribe((event) => {
setStore("state", reconcile(event.state))
setStore("loading", false)
})
onCleanup(() => {
mounted = false
off()
})
})
const current = () => store.state
const localServer = () => platform.localServer
const busy = createMemo(() => !!current()?.job)
const mode = createMemo(() => current()?.config.mode ?? "windows")
const selected = createMemo(() => current()?.checks.distro?.selected)
const run = async (action: () => Promise<void>) => {
try {
await action()
} catch (err) {
requestError(language, err)
}
}
const setMode = async (next: "windows" | "wsl") => {
const state = current()
if (!state || !localServer()) return
await run(() =>
localServer()!.setConfig({
...state.config,
mode: next,
onboarding: {
...state.config.onboarding,
complete: next === "windows",
pendingRestart: next === "windows" ? false : state.config.onboarding.pendingRestart,
step: next === "windows" ? null : state.config.onboarding.step,
},
}),
)
}
const selectDistro = async (name: string) => {
const state = current()
if (!state || !localServer()) return
await run(() =>
localServer()!.setConfig({
...state.config,
mode: "wsl",
distro: name,
onboarding: {
...state.config.onboarding,
complete: false,
step: "distro",
},
}),
)
}
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 local server...</div>}
>
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
<div class="text-14-medium text-text-strong">Runtime</div>
<div class="flex flex-wrap gap-2">
<Button
variant={mode() === "windows" ? "primary" : "secondary"}
size="large"
onClick={() => void setMode("windows")}
>
Run on Windows
</Button>
<Button
variant={mode() === "wsl" ? "primary" : "secondary"}
size="large"
onClick={() => void setMode("wsl")}
>
Run in WSL
</Button>
</div>
<div class="text-12-regular text-text-weak">
Current runtime:{" "}
{current()?.runtime.mode === "wsl"
? `wsl${current()?.runtime.distro ? `:${current()?.runtime.distro}` : ""}`
: "windows"}
</div>
</div>
<Show when={mode() === "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="flex flex-col gap-1 min-w-0">
<div class="text-14-medium text-text-strong">WSL</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">
{current()?.checks.wsl?.error ??
current()?.checks.wsl?.status ??
current()?.checks.wsl?.version ??
"Not checked yet"}
</div>
</div>
<div class="flex gap-2">
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.runStep("wsl"))}
>
Check WSL
</Button>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.installWsl())}
>
Install WSL
</Button>
</div>
</div>
<Show when={current()?.config.onboarding.pendingRestart}>
<div class="text-12-regular text-text-warning-base">
Windows restart required to finish WSL installation.
</div>
</Show>
</div>
<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="flex flex-col gap-1 min-w-0">
<div class="text-14-medium text-text-strong">Distro</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">
{current()?.checks.distro?.error ??
selected()?.name ??
current()?.config.distro ??
"No distro selected"}
</div>
</div>
<div class="flex gap-2">
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.runStep("distro"))}
>
Check distros
</Button>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.installDistro("Debian"))}
>
Install Debian
</Button>
<Button
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => localServer()!.installDistro("Ubuntu-24.04"))}
>
Install Ubuntu 24
</Button>
</div>
<div class="flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Installed distros</div>
<Show
when={(current()?.checks.distro?.installed?.length ?? 0) > 0}
fallback={<div class="text-12-regular text-text-weak">No distros detected yet.</div>}
>
<For each={current()?.checks.distro?.installed ?? []}>
{(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": current()?.config.distro === item.name }}
onClick={() => void selectDistro(item.name)}
>
<div class="text-13-medium text-text-strong">{item.name}</div>
<div class="text-12-regular text-text-weak">
{[item.isDefault ? "default" : null, item.state, item.version ? `WSL ${item.version}` : null]
.filter(Boolean)
.join(" · ")}
</div>
</button>
)}
</For>
</Show>
</div>
<Show when={selected()}>
{(probe) => (
<div class="rounded-md border border-border-weak-base px-3 py-3 flex flex-col gap-1">
<div class="text-12-medium text-text-strong">Selected distro checks</div>
<div class="text-12-regular text-text-weak">
User: {probe().username ?? "unknown"}
{probe().isRoot ? " · root" : ""}
</div>
<div class="text-12-regular text-text-weak">
bash: {probe().hasBash ? "yes" : "no"} · curl: {probe().hasCurl ? "yes" : "no"} · exec:{" "}
{probe().canExecute ? "yes" : "no"}
</div>
</div>
)}
</Show>
<div class="flex gap-2">
<Button
variant="secondary"
size="large"
disabled={busy() || !current()?.config.distro}
onClick={() => void run(() => localServer()!.openTerminal())}
>
Open terminal
</Button>
</div>
</div>
</Show>
<Show when={(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="max-h-56 overflow-y-auto 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>
</div>
)
}
function requestError(language: ReturnType<typeof useLanguage>, err: unknown) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}

View file

@ -11,6 +11,7 @@ import { showToast } from "@opencode-ai/ui/toast"
import { useNavigate } from "@solidjs/router"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { DialogLocalServer } from "@/components/dialog-local-server"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
@ -191,6 +192,9 @@ export function DialogSelectServer() {
showForm: false,
status: undefined as boolean | undefined,
},
localServer: {
showPage: false,
},
editServer: {
id: undefined as string | undefined,
value: "",
@ -419,7 +423,8 @@ export function DialogSelectServer() {
)
}
const mode = createMemo<"list" | "add" | "edit">(() => {
const mode = createMemo<"list" | "local" | "add" | "edit">(() => {
if (store.localServer.showPage) return "local"
if (store.editServer.id) return "edit"
if (store.addServer.showForm) return "add"
return "list"
@ -433,9 +438,11 @@ export function DialogSelectServer() {
const resetForm = () => {
resetAdd()
resetEdit()
setStore("localServer", "showPage", false)
}
const startAdd = () => {
setStore("localServer", "showPage", false)
resetEdit()
setStore("addServer", {
showForm: true,
@ -449,6 +456,7 @@ export function DialogSelectServer() {
}
const startEdit = (conn: ServerConnection.Http) => {
setStore("localServer", "showPage", false)
resetAdd()
setStore("editServer", {
id: conn.http.url,
@ -461,6 +469,12 @@ export function DialogSelectServer() {
})
}
const startLocal = () => {
resetAdd()
resetEdit()
setStore("localServer", "showPage", true)
}
const submitForm = () => {
if (mode() === "add") {
if (addMutation.isPending) return
@ -477,6 +491,7 @@ export function DialogSelectServer() {
const isFormMode = createMemo(() => mode() !== "list")
const isAddMode = createMemo(() => mode() === "add")
const isLocalMode = createMemo(() => mode() === "local")
const formBusy = createMemo(() => (isAddMode() ? addMutation.isPending : editMutation.isPending))
const formTitle = createMemo(() => {
@ -484,7 +499,13 @@ export function DialogSelectServer() {
return (
<div class="flex items-center gap-2 -ml-2">
<IconButton icon="arrow-left" variant="ghost" onClick={resetForm} aria-label={language.t("common.goBack")} />
<span>{isAddMode() ? language.t("dialog.server.add.title") : language.t("dialog.server.edit.title")}</span>
<span>
{isLocalMode()
? "Local Server"
: isAddMode()
? language.t("dialog.server.add.title")
: language.t("dialog.server.edit.title")}
</span>
</div>
)
})
@ -508,130 +529,156 @@ export function DialogSelectServer() {
<Show
when={!isFormMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
<Show
when={isLocalMode()}
fallback={
<ServerForm
value={isAddMode() ? store.addServer.url : store.editServer.value}
name={isAddMode() ? store.addServer.name : store.editServer.name}
username={isAddMode() ? store.addServer.username : store.editServer.username}
password={isAddMode() ? store.addServer.password : store.editServer.password}
placeholder={language.t("dialog.server.add.placeholder")}
busy={formBusy()}
error={isAddMode() ? store.addServer.error : store.editServer.error}
status={isAddMode() ? store.addServer.status : store.editServer.status}
onChange={isAddMode() ? handleAddChange : handleEditChange}
onNameChange={isAddMode() ? handleAddNameChange : handleEditNameChange}
onUsernameChange={isAddMode() ? handleAddUsernameChange : handleEditUsernameChange}
onPasswordChange={isAddMode() ? handleAddPasswordChange : handleEditPasswordChange}
onSubmit={submitForm}
onBack={resetForm}
/>
}
>
<DialogLocalServer />
</Show>
}
>
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => 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 (
<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">
<ServerHealthIndicator health={store.status[key]} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<div class="flex flex-col gap-3">
<Show when={platform.localServer}>
<div class="px-5">
<button
type="button"
class="w-full rounded-md bg-surface-base px-4 py-3 text-left transition-colors hover:bg-surface-base-hover"
onClick={startLocal}
>
<div class="text-14-medium text-text-strong">Local Server</div>
<div class="text-12-regular text-text-weak">Configure Windows or WSL local runtime</div>
</button>
</div>
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<List
search={{
placeholder: language.t("dialog.server.search.placeholder"),
autofocus: false,
}}
noInitialSelection
emptyMessage={language.t("dialog.server.empty")}
items={sortedItems}
key={(x) => 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 (
<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">
<ServerHealthIndicator health={store.status[key]} />
</div>
<ServerRow
conn={i}
dimmed={store.status[key]?.healthy === false}
status={store.status[key]}
class="flex items-center gap-3 min-w-0 flex-1"
badge={
<Show when={defaultKey() === ServerConnection.key(i)}>
<span class="text-text-base bg-surface-base text-14-regular px-1.5 rounded-xs">
{language.t("dialog.server.status.default")}
</span>
</Show>
}
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show when={ServerConnection.key(current()) === key}>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active"
onClick={(e: MouseEvent) => e.stopPropagation()}
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
if (i.type !== "http") return
startEdit(i)
}}
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<Show when={canDefault() && defaultKey() !== key}>
<DropdownMenu.Item onSelect={() => setDefault(key)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.default")}
{language.t("dialog.server.menu.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<Show when={canDefault() && defaultKey() === key}>
<DropdownMenu.Item onSelect={() => setDefault(null)}>
<DropdownMenu.ItemLabel>
{language.t("dialog.server.menu.defaultRemove")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</Show>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => handleRemove(ServerConnection.key(i))}
class="text-text-on-critical-base hover:bg-surface-critical-weak"
>
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</Show>
</div>
</div>
</div>
)
}}
</List>
)
}}
</List>
</div>
</Show>
<div class="px-5 pb-5">
<Show
when={isFormMode()}
when={!isLocalMode() && isFormMode()}
fallback={
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
<Show when={!isLocalMode()}>
<Button
variant="secondary"
icon="plus-small"
size="large"
onClick={startAdd}
class="py-1.5 pl-1.5 pr-3 flex items-center gap-1.5"
>
{language.t("dialog.server.add.button")}
</Button>
</Show>
}
>
<Button variant="primary" size="large" onClick={submitForm} disabled={formBusy()} class="px-3 py-1.5">