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
+17 -3
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
@@ -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(() => {
+7 -10
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 })
}
+13 -16
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(),
@@ -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 {
+44
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',
+10 -7
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
+29 -3
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)
},
}
}