clean maybe

This commit is contained in:
LukeParkerDev
2026-04-17 17:03:41 +10:00
parent 3ad63536bf
commit 3d26bbed82
9 changed files with 166 additions and 186 deletions

View File

@@ -256,9 +256,23 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
size="large"
class="mt-4"
onClick={() => {
void import("@/components/dialog-select-server").then((x) => {
dialog.show(() => <x.DialogSelectServer />)
})
void import("@/components/dialog-select-server")
.then((x) => {
dialog.show(() => (
<x.DialogSelectServer
onNavigateHome={() => {
// We're above the Router here so useNavigate() isn't available.
// Update the browser URL directly; after server.setActive fires
// ServerKey remounts the Router which picks up "/" on init.
// Harmless under MemoryRouter (Electron), which restarts at "/".
if (typeof window !== "undefined" && window.history?.replaceState) {
window.history.replaceState(null, "", "/")
}
}}
/>
))
})
.catch((err) => console.error("Failed to load server dialog", err))
}}
>
Manage servers

View File

@@ -2,7 +2,7 @@ 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, on, onCleanup, Show, Switch } from "solid-js"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { WslServerStep, WslServersState } from "@/context/platform"
@@ -125,14 +125,11 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
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())
createEffect(
on(recommendedStep, (next) => {
setStore("step", next)
}),
)
const autoProbe = createMemo(() => {
const state = current()
if (!state || !wslServers() || busy()) return null
@@ -157,8 +154,19 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
createEffect(() => {
const probe = autoProbe()
if (!probe || probe.key === lastAutoProbe) return
lastAutoProbe = probe.key
void run(probe.run)
const key = probe.key
lastAutoProbe = key
void (async () => {
try {
await probe.run()
} catch (err) {
// 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)
}
})()
})
createEffect(() => {

View File

@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process"
import { existsSync, readFileSync, readdirSync } from "node:fs"
import { dirname, extname, join } from "node:path"
import { wslArgs } from "./wsl"
import { resolveWslHome, runWslInDistro } from "./wsl"
export function checkAppExists(appName: string): boolean {
if (process.platform === "win32") return true
@@ -14,20 +14,17 @@ export function resolveAppPath(appName: string): string | null {
return resolveWindowsAppPath(appName)
}
export function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): string {
export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise<string> {
if (process.platform !== "win32") return path
const flag = mode === "windows" ? "-w" : "-u"
try {
if (path.startsWith("~")) {
const suffix = path.slice(1)
const cmd = `wslpath ${flag} "$HOME${suffix.replace(/"/g, '\\"')}"`
const output = execFileSync("wsl", wslArgs(["sh", "-lc", cmd], distro))
return output.toString().trim()
const resolved = path.startsWith("~") ? `${distro ? await resolveWslHome(distro) : "/root"}${path.slice(1)}` : path
const output = await runWslInDistro(["wslpath", flag, resolved], distro)
if (output.code !== 0) {
throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`)
}
const output = execFileSync("wsl", wslArgs(["wslpath", flag, path], distro))
return output.toString().trim()
return output.stdout.trim()
} catch (error) {
throw new Error(`Failed to run wslpath: ${String(error)}`, { cause: error })
}

View File

@@ -56,23 +56,20 @@ const pendingDeepLinks: string[] = []
const serverReady = defer<ServerReadyData>()
void serverReady.promise.catch(() => undefined)
const wslServers = (() => {
const logger = initLogging()
return createWslServersController(
app.getVersion(),
async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
{
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
)
})()
const logger = initLogging()
const wslServers = createWslServersController(
app.getVersion(),
async (distro) => {
logger.log("spawning wsl sidecar", { distro })
return spawnWslSidecar(distro, {
onLine: (line) => logger.log("wsl sidecar", { distro, stream: line.stream, text: line.text }),
})
},
{
log: (message, meta) => logger.log(message, meta),
error: (message, meta) => logger.error(message, meta),
},
)
logger.log("app starting", {
version: app.getVersion(),

View File

@@ -57,6 +57,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
let state: WslServersState = initialState()
const listeners = new Set<(event: WslServersEvent) => void>()
const sidecars = new Map<string, RunningSidecar>()
const startAttempts = new Map<string, number>()
let jobAbort: AbortController | undefined
const emit = () => {
@@ -116,14 +117,38 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
updateServer(id, (item) => ({ ...item, runtime }))
}
const nextStartAttempt = (id: string) => {
const next = (startAttempts.get(id) ?? 0) + 1
startAttempts.set(id, next)
return next
}
const invalidateStartAttempt = (id: string) => {
startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1)
}
const isCurrentStartAttempt = (id: string, attempt: number) => {
return startAttempts.get(id) === attempt && state.servers.some((item) => item.config.id === id)
}
const startServer = async (id: string) => {
const item = state.servers.find((x) => x.config.id === id)
if (!item) return
const attempt = nextStartAttempt(id)
await stopServerInternal(id)
if (!isCurrentStartAttempt(id, attempt)) return
setRuntime(id, { kind: "starting" })
mainLogger?.log("wsl sidecar starting", { id, distro: item.config.distro })
try {
const sidecar = await spawnSidecar(item.config.distro)
if (!isCurrentStartAttempt(id, attempt)) {
try {
sidecar.listener.stop()
} catch {
// ignore stop errors for stale sidecars
}
return
}
sidecars.set(id, sidecar)
setRuntime(id, {
kind: "ready",
@@ -134,6 +159,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
mainLogger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!isCurrentStartAttempt(id, attempt)) return
setRuntime(id, { kind: "failed", message })
// Without this, an Ubuntu-style silent failure leaves no trace in
// main.log — the controller captures the message in its state but
@@ -327,6 +353,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
},
async removeServer(id: string) {
invalidateStartAttempt(id)
await stopServerInternal(id)
const remaining = readPersistedServers().filter((item) => item.id !== id)
persistServers(remaining)
@@ -336,6 +363,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
startServer,
async stopServer(id: string) {
invalidateStartAttempt(id)
await stopServerInternal(id)
setRuntime(id, { kind: "stopped" })
},
@@ -350,6 +378,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
},
stopAll() {
for (const item of state.servers) invalidateStartAttempt(item.config.id)
for (const [id] of sidecars) {
const existing = sidecars.get(id)
try {

View File

@@ -353,11 +353,55 @@ export async function probeWslDistro(name: string, opts?: RunWslOptions): Promis
}
}
async function readWslDefaultUser(distro: string, opts?: RunWslOptions) {
const entry = (await readWslDistrosFromRegistry(opts)).find((item) => item.name === distro)
if (!entry || entry.defaultUid === 0) return null
const passwd = firstLine(
(
await runWslSh(
[
"if command -v getent >/dev/null 2>&1; then",
` getent passwd ${entry.defaultUid}`,
"else",
` awk -F: '$3 == ${entry.defaultUid} { print; exit }' /etc/passwd`,
"fi",
].join("\n"),
distro,
opts,
)
).stdout,
)
if (!passwd) return null
const parts = passwd.split(":")
const username = parts[0]?.trim() ?? ""
const home = parts[5]?.trim() ?? ""
if (!home) return null
return { username: username || null, home }
}
export async function resolveWslHome(distro: string, opts?: RunWslOptions) {
return (await readWslDefaultUser(distro, opts))?.home ?? "/root"
}
function opencodeCandidate(path: string) {
return `if [ -x ${shellEscape(path)} ]; then printf "%s\\n" ${shellEscape(path)}; fi`
}
export async function resolveWslOpencode(distro: string, opts?: RunWslOptions) {
const command = firstLine((await runWslSh("command -v opencode 2>/dev/null || true", distro, opts)).stdout)
if (command && !command.startsWith("/mnt/")) return command
const home = await resolveWslHome(distro, opts)
for (const candidate of [
...(home !== "/root"
? [
opencodeCandidate(`${home}/.local/bin/opencode`),
opencodeCandidate(`${home}/bin/opencode`),
opencodeCandidate(`${home}/.opencode/bin/opencode`),
]
: []),
'if [ -x "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode" ]; then printf "%s\\n" "${XDG_BIN_DIR:-$HOME/.local/bin}/opencode"; fi',
'if [ -x "$HOME/bin/opencode" ]; then printf "%s\\n" "$HOME/bin/opencode"; fi',
'if [ -x "$HOME/.opencode/bin/opencode" ]; then printf "%s\\n" "$HOME/.opencode/bin/opencode"; fi',

View File

@@ -1,6 +1,7 @@
import { Dialog as Kobalte } from "@kobalte/core/dialog"
import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js"
import { ComponentProps, createEffect, JSXElement, Match, ParentProps, Show, Switch, useContext } from "solid-js"
import { useI18n } from "../context/i18n"
import { DialogContext } from "../context/dialog"
import { IconButton } from "./icon-button"
export interface DialogProps extends ParentProps {
@@ -12,11 +13,19 @@ export interface DialogProps extends ParentProps {
classList?: ComponentProps<"div">["classList"]
fit?: boolean
transition?: boolean
// When `false`, clicking the overlay or outside the dialog will not dismiss it.
// Default is `true`.
dismissOutside?: boolean
}
export function Dialog(props: DialogProps) {
const i18n = useI18n()
const dialogCtx = useContext(DialogContext)
createEffect(() => {
if (!dialogCtx) return
if (props.dismissOutside === undefined) return
dialogCtx.active?.setDismissOutside(props.dismissOutside)
})
return (
<div
data-component="dialog"
@@ -32,12 +41,6 @@ export function Dialog(props: DialogProps) {
...props.classList,
[props.class ?? ""]: !!props.class,
}}
onInteractOutside={(e) => {
if (props.dismissOutside === false) e.preventDefault()
}}
onPointerDownOutside={(e) => {
if (props.dismissOutside === false) e.preventDefault()
}}
onOpenAutoFocus={(e) => {
const target = e.currentTarget as HTMLElement | null
const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null

View File

@@ -23,10 +23,14 @@ type Active = {
owner: Owner
onClose?: () => void
setClosing: (closing: boolean) => void
dismissOutside: () => boolean
setDismissOutside: (value: boolean) => void
}
const Context = createContext<ReturnType<typeof init>>()
export const DialogContext = Context
function init() {
const [active, setActive] = createSignal<Active | undefined>()
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
@@ -89,12 +93,17 @@ function init() {
const id = Math.random().toString(36).slice(2)
let dispose: (() => void) | undefined
let setClosing: ((closing: boolean) => void) | undefined
let setDismissOutsideSignal: ((value: boolean) => void) | undefined
let dismissOutsideAccessor: (() => boolean) | undefined
const node = runWithOwner(owner, () =>
createRoot((d: () => void) => {
dispose = d
const [closing, setClosingSignal] = createSignal(false)
setClosing = setClosingSignal
const [dismissOutside, setDismissOutside] = createSignal(true)
dismissOutsideAccessor = dismissOutside
setDismissOutsideSignal = setDismissOutside
return (
<Kobalte
modal
@@ -105,7 +114,12 @@ function init() {
}}
>
<Kobalte.Portal>
<Kobalte.Overlay data-component="dialog-overlay" />
<Kobalte.Overlay
data-component="dialog-overlay"
onClick={() => {
if (dismissOutside()) close()
}}
/>
{element()}
</Kobalte.Portal>
</Kobalte>
@@ -113,9 +127,18 @@ function init() {
}),
)
if (!dispose || !setClosing) return
if (!dispose || !setClosing || !dismissOutsideAccessor || !setDismissOutsideSignal) return
setActive({ id, node, dispose, owner, onClose, setClosing })
setActive({
id,
node,
dispose,
owner,
onClose,
setClosing,
dismissOutside: dismissOutsideAccessor,
setDismissOutside: setDismissOutsideSignal,
})
}
return {
@@ -159,5 +182,8 @@ export function useDialog() {
close() {
ctx.close()
},
setDismissOutside(value: boolean) {
ctx.active?.setDismissOutside(value)
},
}
}

138
plan.md
View File

@@ -1,138 +0,0 @@
# WSL Server Implementation Backlog
This backlog assumes Electron only.
It is ordered chronologically.
Each task is intended to be small enough for a new engineer to pick up directly.
## Direction
- Local Server is **always** Windows-native local on Windows.
- Local Server has no runtime swap and no WSL mode.
- WSL servers are a **separate, additive** concept.
- A Windows user can add zero or more WSL servers; each is bound to a specific distro.
- Each WSL server runs as its own sidecar alongside the Windows Local Server.
- Adding/removing a WSL server is hot; no app restart required.
- Manage Servers UI exposes an `Add WSL` button (Windows only) that opens the wizard.
## 01 Electron Config Split
- [x] Remove runtime `mode` / `distro` from the persisted Local Server config.
- [x] Introduce a new persisted `wslServers` key holding an array of `WslServerConfig`.
- [x] Define `WslServerConfig` as `{ id, distro, onboarding, acknowledgements }`.
- [x] Keep onboarding metadata per WSL server, not globally.
- [x] Keep acknowledgement state per WSL server.
- [x] Migrate any legacy `localServer.mode === "wsl"` entry into a single `wslServers` entry on read.
- [x] Drop all references to `LocalServerMode` from the preload types.
## 02 Main-Process Multi-Sidecar Startup
- [x] Always start the Windows local sidecar on app launch.
- [x] After Windows local is spawned, iterate each `WslServerConfig` and spawn a WSL sidecar per entry.
- [x] Give each WSL sidecar its own port and password.
- [x] Allocate the Windows local port/password once per launch (unchanged).
- [x] Track all sidecars in a single map keyed by server id.
- [x] Kill all sidecars on `before-quit` / `will-quit` / signal.
- [x] Include Windows local data in the startup payload unchanged.
- [x] Include the initial set of WSL servers (with url/password/status) in the startup payload.
- [x] Emit per-WSL-server lifecycle events (`starting`, `ready`, `failed`, `stopping`, `removed`).
- [x] On startup, do not block the main window on WSL sidecar health.
- [x] If a WSL sidecar fails health, keep it in the list and mark it failed instead of hanging startup.
## 03 WSL Server Controller
- [x] Create a main-process controller that owns `wslServers` persistence and runtime state.
- [x] Expose typed events (`state`, per-item status changes) from the controller.
- [x] Support one in-flight job per WSL server (not a global in-flight job).
- [x] Implement `addServer(distro)` that persists, then spawns and health-checks a new sidecar.
- [x] Implement `removeServer(id)` that stops the sidecar and removes it from config.
- [x] Implement per-server `runStep`, `cancelJob`, `installWsl`, `installDistro`, `installOpencode`, `openTerminal`.
- [x] Reuse the existing WSL process helpers unchanged.
- [x] Keep transcripts per server, only for the current app launch.
## 04 IPC / Preload Surface
- [x] Rename `localServer.*` IPC channels to `wslServers.*`.
- [x] Add `wslServers.getState()` returning the full list plus per-server runtime info.
- [x] Add `wslServers.subscribe()` with unsubscribe support.
- [x] Add `wslServers.add(distro)` (persists config + starts sidecar).
- [x] Add `wslServers.remove(id)`.
- [x] Add `wslServers.runStep(id, step)`.
- [x] Add `wslServers.cancelJob(id)`.
- [x] Add `wslServers.installWsl(id)`.
- [x] Add `wslServers.installDistro(id, distro)`.
- [x] Add `wslServers.installOpencode(id)`.
- [x] Add `wslServers.openTerminal(id)`.
- [x] Remove obsolete `localServer.setConfig` / `localServer.*` channels.
- [x] Include url/username/password for each WSL server in the state payload (after sidecar start).
## 05 Renderer Platform Wiring
- [x] Expose `platform.wslServers` as a reactive accessor (list + subscribe).
- [x] Remove `platform.localServer` runtime swap APIs.
- [x] Keep `platform.wslServers` API available on Windows only.
- [x] Keep distro-aware path conversion keyed by the active WSL server.
- [x] When the active server is a WSL sidecar, default pickers to that distro's home.
- [x] When the active server is the Windows Local Server, keep native Windows picker defaults.
## 06 Renderer Server List
- [x] Always include the Windows Local Server in the server list.
- [x] Include each configured WSL server in the server list with `ServerConnection.Sidecar` variant `wsl`.
- [x] Keep `ServerConnection.key` returning `local:windows` for the Windows Local Server.
- [x] Keep `ServerConnection.key` returning `wsl:<distro>` for WSL servers (one per distro).
- [x] Keep distinct `projectsKey` buckets for Windows local vs each WSL server.
- [x] Do not collapse a WSL server into the `local` projects bucket.
## 07 Manage Servers UI
- [x] Remove `Swap to WSL` and `Swap to Windows` buttons from the Local Server row.
- [x] Show the Local Server row exactly like any other server (health, name, active check).
- [x] Add an `Add WSL` button next to `Add server`, visible only on Windows when the platform supports WSL.
- [x] `Add WSL` opens the same wizard stepper, scoped to a new WSL server draft.
- [x] Each WSL server row behaves like a sidecar entry (selectable, default-able, removable).
- [x] Add a `Remove` action to the WSL server row menu.
- [x] Add a `Retry setup` action when a WSL server is unhealthy.
## 08 Add WSL Wizard
- [x] Replace the "Switch" step with a `Done` step.
- [x] Step order becomes `WSL -> Distro -> OpenCode -> Done`.
- [x] On `Done`, persist the new WSL server, start the sidecar, and close the dialog.
- [x] If the user cancels, do not persist anything.
- [x] Allow resuming an incomplete WSL server wizard from Manage Servers.
- [x] Remove restart-to-apply copy, restart toasts, and "Use Windows" CTA.
- [x] Keep failure-only diagnostics panel behavior.
## 09 Per-WSL Onboarding State
- [x] Keep `WslServerConfig.onboarding` per server.
- [x] Keep `WslServerConfig.acknowledgements` per server.
- [x] Resume the wizard for any server where `onboarding.complete === false`.
- [x] Mark onboarding complete only after the sidecar becomes healthy.
## 10 Connection Error Path
- [x] If the active server is a WSL sidecar and health fails, offer `Open setup` that deep-links into the wizard for that server.
- [x] Keep behavior unchanged for the Windows Local Server.
- [x] Keep behavior unchanged for remote HTTP servers.
## 11 Legacy Removal
- [x] Remove `Swap to WSL` / `Swap to Windows` popover components.
- [x] Remove `restart-to-apply` banner copy and helpers from `dialog-local-server.tsx`.
- [x] Remove legacy `wslEnabled` preload shims.
- [x] Remove the old `DialogSelectServer` `initialTargetMode` prop.
- [x] Drop `localServerKey(config)` distinguishing `wsl` vs `windows` in the controller (local is always Windows).
## 12 Verification
- [ ] Verify the Windows Local Server starts unchanged on app launch.
- [ ] Verify `Add WSL` opens the wizard with the default installed distro preselected.
- [ ] Verify adding a WSL server spawns a new sidecar without restarting the app.
- [ ] Verify removing a WSL server stops the sidecar and removes it from the list.
- [ ] Verify multiple WSL servers can coexist, one per distro.
- [ ] Verify Windows Local Server stays active while WSL sidecars come and go.
- [ ] Verify a failed WSL sidecar does not block app startup or window creation.
- [ ] Verify the `ConnectionError` deep-link reaches the right wizard scope.
- [ ] Verify legacy `localServer.mode === "wsl"` persisted config migrates into `wslServers` on first launch.
- [ ] Verify project histories stay separate per server key.