This commit is contained in:
LukeParkerDev
2026-04-28 09:03:48 +10:00
parent 4f6caacde4
commit 7bcb519e0c
21 changed files with 228 additions and 834 deletions

View File

@@ -31,7 +31,6 @@ import {
Suspense,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { serverSwitching, withServerSwitchOverlay } from "@/utils/server-switch"
import { CommandProvider } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
@@ -52,7 +51,7 @@ import { WslServersProvider } from "@/context/wsl-servers"
import DirectoryLayout from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { isPlaceholderServerUrl, useCheckServerHealth } from "./utils/server-health"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const loadSession = () => import("@/pages/session")
@@ -198,8 +197,6 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
: Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
if (isPlaceholderServerUrl(http.url)) return false
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
@@ -233,14 +230,12 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
void withServerSwitchOverlay(() =>
startTransition(() => {
batch(() => {
setCheckMode("blocking")
server.setActive(key)
})
}),
)
startTransition(() => {
batch(() => {
setCheckMode("blocking")
server.setActive(key)
})
})
}}
/>
}
@@ -362,11 +357,6 @@ export function AppInterface(props: {
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<Show when={serverSwitching()}>
<div class="fixed inset-0 z-[2147483647] bg-background-base flex flex-col items-center justify-center pointer-events-auto">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
</Show>
<ServerKey>
<QueryProvider>
<GlobalSDKProvider>

View File

@@ -17,11 +17,9 @@ import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useWslServers } from "@/context/wsl-servers"
import { isPlaceholderServerUrl, type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
import { withServerSwitchOverlay } from "@/utils/server-switch"
import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
const DEFAULT_USERNAME = "opencode"
const cachedServerStatus = new Map<ServerConnection.Key, ServerHealth>()
function versionOlderThan(current: string | null | undefined, expected: string | null | undefined) {
if (!current || !expected) return false
@@ -168,7 +166,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
},
addWsl: {
showWizard: props.initialView === "add-wsl",
pendingSelectKey: undefined as ServerConnection.Key | undefined,
},
editServer: {
id: undefined as string | undefined,
@@ -322,8 +319,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
)
.join("\n\n"),
)
const health = (key: ServerConnection.Key) => store.status[key] ?? cachedServerStatus.get(key)
const isSelectable = (conn: ServerConnection.Any) => !isPlaceholderServerUrl(conn.http.url)
const health = (key: ServerConnection.Key) => store.status[key]
const wslRuntime = (conn: ServerConnection.Any) => {
if (!isWslSidecar(conn)) return
return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime
@@ -361,9 +357,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
}),
)
if (disposed) return
for (const [key, value] of Object.entries(results)) {
cachedServerStatus.set(ServerConnection.Key.make(key), value)
}
setStore("status", reconcile(results))
}
@@ -380,7 +373,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
}
async function select(conn: ServerConnection.Any, persist?: boolean) {
if (!isSelectable(conn)) return
if (!persist && health(ServerConnection.key(conn))?.healthy === false) return
const nextKey = ServerConnection.key(conn)
const changed = server.key !== nextKey
@@ -413,18 +405,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
return
}
await withServerSwitchOverlay(apply)
apply()
}
createEffect(() => {
const key = store.addWsl.pendingSelectKey
if (!key) return
const conn = items().find((item) => ServerConnection.key(item) === key)
if (!conn || !isSelectable(conn)) return
setStore("addWsl", "pendingSelectKey", undefined)
void select(conn)
})
const handleAddChange = (value: string) => {
if (addMutation.isPending) return
setStore("addServer", { url: value, error: "" })
@@ -480,7 +463,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const resetForm = () => {
resetAdd()
resetEdit()
setStore("addWsl", "pendingSelectKey", undefined)
setStore("addWsl", "showWizard", false)
}
@@ -513,19 +495,14 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const startAddWsl = () => {
resetAdd()
resetEdit()
setStore("addWsl", "pendingSelectKey", undefined)
setStore("addWsl", "showWizard", true)
}
const handleAddedWsl = async (distro: string) => {
const key = ServerConnection.Key.make(`wsl:${distro}`)
setStore("addWsl", "showWizard", false)
setStore("addWsl", "pendingSelectKey", key)
const conn = items().find((item) => ServerConnection.key(item) === key)
if (conn && isSelectable(conn)) {
await select(conn)
setStore("addWsl", "pendingSelectKey", undefined)
}
if (conn) await select(conn)
}
const submitForm = () => {
@@ -593,7 +570,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
return (
<Dialog
title={formTitle()}
dismissOutside={!isAddWslMode()}
fit={isAddWslMode()}
class={isAddWslMode() ? "[&_[data-slot=dialog-body]]:flex-none [&_[data-slot=dialog-body]]:overflow-visible" : undefined}
>
@@ -644,7 +620,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
const key = ServerConnection.key(i)
const wsl = isWslSidecar(i)
const wslDistro = wsl ? i.distro : undefined
const blocked = () => !isSelectable(i) || health(key)?.healthy === false
const blocked = () => health(key)?.healthy === false
const canChangeDefault = () => canDefault() && i.type !== "ssh"
const canRemove = () => i.type === "http" || wsl
const hasMenuActionsBeforeDelete = () => canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))

View File

@@ -1,12 +1,11 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Spinner } from "@opencode-ai/ui/spinner"
import { useMutation } from "@tanstack/solid-query"
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 type { WslServersPlatform, WslServerStep } from "@/context/platform"
import type { WslServerStep } from "@/context/platform"
import { usePlatform } from "@/context/platform"
import { useWslServers } from "@/context/wsl-servers"
@@ -16,14 +15,6 @@ function isHiddenDistro(name: string) {
return /^docker-desktop(?:-data)?$/i.test(name)
}
function parseProgressPercent(text: string) {
const match = text.match(/(\d{1,3}(?:[.,]\d+)?)\s*%/)
if (!match) return null
const value = Number.parseFloat(match[1]!.replace(",", "."))
if (!Number.isFinite(value)) return null
return Math.max(0, Math.min(99, Math.floor(value)))
}
interface DialogWslServerProps {
onAdded?: (distro: string) => void | Promise<void>
}
@@ -33,6 +24,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
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,
@@ -40,12 +32,6 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
adding: false,
})
const current = () => wslServers.data
const wslApi = () => platform.wslServers
const withWslApi = async <T,>(run: (api: WslServersPlatform) => Promise<T>) => {
const api = wslApi()
if (!api) return
return run(api)
}
let disposed = false
onCleanup(() => {
disposed = true
@@ -104,15 +90,6 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
})
const installTarget = createMemo(() => installableDistros().find((item) => item.name === store.installTarget) ?? null)
const installingDistro = createMemo(() => current()?.job?.kind === "install-distro")
const installDistroPercent = createMemo(() => {
if (!installingDistro()) return null
const transcript = current()?.transcript ?? []
for (let i = transcript.length - 1; i >= 0; i--) {
const percent = parseProgressPercent(transcript[i]!.text)
if (percent !== null) return percent
}
return null
})
const wslReady = createMemo(() => !!current()?.runtime?.available && !current()?.pendingRestart)
const distroReady = createMemo(() => {
const probe = selectedProbe()
@@ -135,58 +112,22 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
// them back when a probe result updates recommendedStep.
const activeStep = createMemo(() => store.step ?? recommendedStep())
const probeRuntimeMutation = useMutation(() => ({
mutationFn: () => withWslApi((api) => api.probeRuntime()),
}))
const refreshDistrosMutation = useMutation(() => ({
mutationFn: () => withWslApi((api) => api.refreshDistros()),
}))
const installWslMutation = useMutation(() => ({
mutationFn: () => withWslApi((api) => api.installWsl()),
}))
const installDistroMutation = useMutation(() => ({
mutationFn: (name: string) => withWslApi((api) => api.installDistro(name)),
}))
const probeDistroMutation = useMutation(() => ({
mutationFn: (name: string) => withWslApi((api) => api.probeDistro(name)),
}))
const probeOpencodeMutation = useMutation(() => ({
mutationFn: (name: string) => withWslApi((api) => api.probeOpencode(name)),
}))
const installOpencodeMutation = useMutation(() => ({
mutationFn: (name: string) => withWslApi((api) => api.installOpencode(name)),
}))
const openTerminalMutation = useMutation(() => ({
mutationFn: (name: string) => withWslApi((api) => api.openTerminal(name)),
}))
const addServerMutation = useMutation(() => ({
mutationFn: (distro: string) => withWslApi((api) => api.addServer(distro)),
}))
const autoProbe = createMemo(() => {
const state = current()
if (!state || !wslApi() || busy()) return null
if (!state || busy()) return null
if (state.pendingRestart) return null
if (!state.runtime) return { key: "runtime", run: () => probeRuntimeMutation.mutateAsync() }
if (!state.runtime) return { key: "runtime", run: () => api.probeRuntime() }
if (!wslReady()) return null
if (!state.installed.length && !state.online.length) {
return { key: "distros", run: () => refreshDistrosMutation.mutateAsync() }
return { key: "distros", run: () => api.refreshDistros() }
}
const distro = store.selectedDistro
if (distro && !state.distroProbes[distro]) {
return { key: `probe-distro:${distro}`, run: () => probeDistroMutation.mutateAsync(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: () => probeOpencodeMutation.mutateAsync(distro) }
return { key: `probe-opencode:${distro}`, run: () => api.probeOpencode(distro) }
}
return null
})
@@ -266,18 +207,6 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
return distro ? `Install OpenCode in ${distro}.` : "Choose a distro first."
})
const installProgress = createMemo(() => {
const state = current()
if (!state?.job) return null
const transcript = state.transcript.filter((line) => line.text.trim())
const title = transcript[0]?.text
if (!title?.startsWith("Installing ")) return null
return {
title,
lines: transcript.slice(1).slice(-8),
}
})
const run = async (action: () => Promise<unknown>) => {
try {
await action()
@@ -302,7 +231,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
if (!distro) return
setStore("adding", true)
try {
await addServerMutation.mutateAsync(distro)
await api.addServer(distro)
if (props.onAdded) {
await props.onAdded(distro)
} else {
@@ -370,7 +299,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="secondary"
size="large"
disabled={busy()}
onClick={() => void run(() => installWslMutation.mutateAsync())}
onClick={() => void run(() => api.installWsl())}
>
Install WSL
</Button>
@@ -402,7 +331,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="ghost"
size="small"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => probeDistroMutation.mutateAsync(distro))}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
Refresh
</Button>
@@ -446,11 +375,6 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
<div class="px-1 flex items-center justify-between gap-3">
<div class="text-12-medium text-text-weak">Install</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={installingDistro() && installDistroPercent() !== null}>
<span class="text-12-regular text-text-weak shrink-0 tabular-nums min-w-[3ch] text-right">
{installDistroPercent()}%
</span>
</Show>
<Show when={installingDistro()}>
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
</Show>
@@ -458,7 +382,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="secondary"
size="small"
disabled={busy() || !installTarget()}
onClick={() => void run(() => installDistroMutation.mutateAsync(installTarget()!.name))}
onClick={() => void run(() => api.installDistro(installTarget()!.name))}
>
{installingDistro() ? "Installing..." : "Install"}
</Button>
@@ -531,7 +455,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="secondary"
size="large"
disabled={busy() || !selectedInstalled()}
onClick={() => runSelectedDistro((distro) => openTerminalMutation.mutateAsync(distro))}
onClick={() => runSelectedDistro((distro) => api.openTerminal(distro))}
>
Open terminal
</Button>
@@ -539,7 +463,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="ghost"
size="large"
disabled={busy() || !store.selectedDistro}
onClick={() => runSelectedDistro((distro) => probeDistroMutation.mutateAsync(distro))}
onClick={() => runSelectedDistro((distro) => api.probeDistro(distro))}
>
Refresh
</Button>
@@ -568,7 +492,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="ghost"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => probeOpencodeMutation.mutateAsync(distro))}
onClick={() => runSelectedDistro((distro) => api.probeOpencode(distro))}
>
Refresh
</Button>
@@ -578,7 +502,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
variant="secondary"
size="large"
disabled={busy()}
onClick={() => runSelectedDistro((distro) => installOpencodeMutation.mutateAsync(distro))}
onClick={() => runSelectedDistro((distro) => api.installOpencode(distro))}
>
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
</Button>
@@ -606,47 +530,6 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
</Match>
</Switch>
<Show when={installProgress()}>
{(progress) => (
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
<div class="flex items-center gap-2 text-14-medium text-text-strong">
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
<div>Progress</div>
</div>
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{progress().title}</div>
<div class="rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular whitespace-pre-wrap break-words">
<For
each={
progress().lines.length
? progress().lines
: [{ stream: "system" as const, text: "Waiting for output...", at: 0 }]
}
>
{(line) => (
<div
classList={{
"text-text-warning-base": line.stream === "stderr",
"text-text-weak": line.stream !== "stderr",
}}
>
{line.text}
</div>
)}
</For>
</div>
</div>
)}
</Show>
<Show when={current()?.lastError && (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="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 when={activeStep() === "opencode" && allReady() && store.selectedDistro}>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>

View File

@@ -14,8 +14,7 @@ import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { isPlaceholderServerUrl, useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { withServerSwitchOverlay } from "@/utils/server-switch"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
const pollMs = 10_000
@@ -243,7 +242,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
<For each={sortedServers()}>
{(s) => {
const key = ServerConnection.key(s)
const blocked = () => isPlaceholderServerUrl(s.http.url) || health[key]?.healthy === false
const blocked = () => health[key]?.healthy === false
return (
<button
type="button"
@@ -255,21 +254,19 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
aria-disabled={blocked()}
onClick={() => {
if (blocked()) return
void withServerSwitchOverlay(() =>
startTransition(() => {
batch(() => {
if (server.key !== key) {
if (typeof window !== "undefined" && window.history?.replaceState) {
window.history.replaceState(null, "", "/")
}
} else {
navigate("/")
startTransition(() => {
batch(() => {
if (server.key !== key) {
if (typeof window !== "undefined" && window.history?.replaceState) {
window.history.replaceState(null, "", "/")
}
server.setActive(key)
})
}),
)
}}
} else {
navigate("/")
}
server.setActive(key)
})
})
}}
>
<ServerHealthIndicator health={health[key]} />
<ServerRow

View File

@@ -62,6 +62,26 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
},
}
const getTerminalColors = (theme: ReturnType<typeof useTheme>): TerminalColors => {
const mode = theme.mode() === "dark" ? "dark" : "light"
const fallback = DEFAULT_TERMINAL_COLORS[mode]
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
return {
background,
foreground: text,
cursor: text,
selectionBackground: withAlpha(base, alpha),
}
}
const debugTerminal = (...values: unknown[]) => {
if (!import.meta.env.DEV) return
console.debug("[terminal]", ...values)
@@ -238,28 +258,7 @@ export const Terminal = (props: TerminalProps) => {
})
}
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode() === "dark" ? "dark" : "light"
const fallback = DEFAULT_TERMINAL_COLORS[mode]
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds && !variant?.palette) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha)
return {
background,
foreground: text,
cursor: text,
selectionBackground,
}
}
const terminalColors = createMemo(getTerminalColors)
const terminalColors = createMemo(() => getTerminalColors(theme))
const scheduleFit = () => {
if (disposed) return
@@ -643,29 +642,6 @@ export const Terminal = (props: TerminalProps) => {
socket.addEventListener("close", handleClose)
}
// If we're reconnecting to a saved pty AND we have a serialised buffer
// to replay, verify the pty still exists on the current sidecar BEFORE
// upgrading the WebSocket. Hono's upgradeWebSocket handler throws
// "Session not found" inside `onOpen` (packages/opencode/src/server/
// routes/instance/pty.ts:196-205), which means the client still gets a
// brief `open` event before the server closes the socket — enough to
// fire handleOpen and paint the stale buffer. Pre-checking turns this
// into a single pty.get() round-trip that routes directly into the
// clone path on NotFound, so restore never runs against a dead pty.
if (restore) {
logTerminal("restore.inspect", {
id,
serverKey: server.key ?? null,
directory,
restoreLength: restore.length,
})
if (await gone()) {
if (!disposed) fail(new Error("Session not found"))
return
}
if (disposed) return
}
open()
}

View File

@@ -44,12 +44,6 @@ export type WslOpencodeCheck = {
matchesDesktop: boolean | null
error: string | null
}
export type WslTranscriptLine = {
stream: "stdout" | "stderr" | "system"
text: string
at: number
}
export type WslServerAcknowledgements = {
root: boolean
mismatch: { path: string; version: string } | null
@@ -90,8 +84,6 @@ export type WslServersState = {
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
transcript: WslTranscriptLine[]
lastError: string | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }

View File

@@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createEffect, createMemo, onCleanup, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { isPlaceholderServerUrl, useCheckServerHealth } from "@/utils/server-health"
import { useCheckServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
@@ -252,10 +252,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
setState("healthy", true)
return
}
if (isPlaceholderServerUrl(current_.http.url)) {
setState("healthy", false)
return
}
setState("healthy", undefined)
console.log(`[server health] start polling key=${ServerConnection.key(current_)} url=${current_.http.url}`)
onCleanup(startHealthPolling(current_))

View File

@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
let getWorkspaceTerminalCacheKey: (dir: string, serverKey: string) => string
let getWorkspaceTerminalCacheKey: (dir: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
@@ -22,9 +22,8 @@ beforeAll(async () => {
})
describe("getWorkspaceTerminalCacheKey", () => {
test("includes the server in the workspace cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo", "local:windows")).toBe("/repo:local:windows:__workspace__")
expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("/repo:wsl:Debian:__workspace__")
test("uses the workspace cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
})
})

View File

@@ -88,8 +88,8 @@ export function migrateTerminalState(value: unknown) {
}
}
export function getWorkspaceTerminalCacheKey(dir: string, serverKey: string) {
return `${dir}:${serverKey}:${WORKSPACE_KEY}`
export function getWorkspaceTerminalCacheKey(dir: string) {
return `${dir}:${WORKSPACE_KEY}`
}
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
@@ -430,15 +430,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
onCleanup(() => caches.delete(cache))
const disposeAll = () => {
// Snapshot disposers, then defer them to a macrotask. When this runs
// from onCleanup during a parent remount (e.g. switching servers),
// calling dispose() synchronously starts a nested cleanNode cascade on
// a sibling root while the outer cascade is mid-traversal, corrupting
// solid-js's graph walk state and throwing `Cannot read properties of
// null (reading '1')` at chunk-*.js:992.
const pending = Array.from(cache.values(), (entry) => entry.dispose)
cache.clear()
if (pending.length) setTimeout(() => pending.forEach((d) => d()), 0)
for (const dispose of pending) dispose()
}
onCleanup(disposeAll)
@@ -454,10 +448,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const loadWorkspace = (dir: string, serverKey: string, legacySessionID?: string) => {
// Session ids, PTY ids, and terminal buffers are server-scoped. Project
// swaps remount this subtree, but server swaps do not, so the in-memory
// cache must be partitioned by server as well as directory.
const key = getWorkspaceTerminalCacheKey(dir, serverKey)
const key = getWorkspaceTerminalCacheKey(dir)
const existing = cache.get(key)
if (existing) {
debugTerminal("workspace.cache.hit", {
@@ -488,39 +479,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const unsupported = createMemo(() => {
const current = server.current
return current?.type === "sidecar" && current.variant === "wsl" && params.dir?.startsWith("/mnt/")
})
const unsupportedWorkspace = {
ready: () => true,
all: () => [] as LocalPTY[],
active: () => undefined as string | undefined,
clear() {},
new() {},
update(_pty: Partial<LocalPTY> & { id: string }) {},
trim(_id: string) {},
trimAll() {},
clone: async (_id: string) => {},
bind() {
return {
trim(_id: string) {},
update(_pty: Partial<LocalPTY> & { id: string }) {},
clone: async (_id: string) => {},
}
},
open(_id: string) {},
close: async (_id: string) => {},
move(_id: string, _to: number) {},
next() {},
previous() {},
} as unknown as ReturnType<typeof createWorkspaceTerminalSession>
const workspace = createMemo(() => {
if (unsupported()) return unsupportedWorkspace
const key = server.key
if (!key) return unsupportedWorkspace
if (!key) return loadWorkspace(params.dir!, "", params.id)
return loadWorkspace(params.dir!, key, params.id)
})

View File

@@ -20,14 +20,6 @@ const healthCache = new Map<
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
>()
export function isPlaceholderServerUrl(url: string) {
try {
return new URL(url).hostname.endsWith(".invalid")
} catch {
return false
}
}
function cacheKey(server: ServerConnection.HttpBase) {
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
}
@@ -93,7 +85,6 @@ export async function checkServerHealth(
fetch: typeof globalThis.fetch,
opts?: CheckServerHealthOptions,
): Promise<ServerHealth> {
if (isPlaceholderServerUrl(server.url)) return { healthy: false }
const timeout = opts?.signal ? undefined : timeoutSignal(opts?.timeoutMs ?? defaultTimeoutMs)
const signal = opts?.signal ?? timeout?.signal
const retryCount = opts?.retryCount ?? defaultRetryCount

View File

@@ -1,32 +0,0 @@
import { createSignal } from "solid-js"
// Global flag used to paint a full-window splash overlay while a server
// swap is in progress. ServerKey's keyed <Show> remount is a big
// synchronous cascade (dispose + remount of the entire app subtree) that
// can freeze the UI for several seconds; setting this true before the
// swap and false after lets us render an overlay above the ServerKey
// boundary so the freeze has visual feedback instead of looking stuck.
export const [serverSwitching, setServerSwitching] = createSignal(false)
let run = 0
const nextPaint = () =>
new Promise<void>((resolve) => {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => resolve())
return
}
setTimeout(resolve, 0)
})
export async function withServerSwitchOverlay(action: () => void | Promise<void>) {
const token = ++run
setServerSwitching(true)
await nextPaint()
try {
await action()
} finally {
await nextPaint()
if (run === token) setServerSwitching(false)
}
}

View File

@@ -1,8 +1,6 @@
import { randomUUID } from "node:crypto"
import { EventEmitter } from "node:events"
import { existsSync } from "node:fs"
import * as nodeHttp from "node:http"
import * as nodeHttps from "node:https"
import { homedir } from "node:os"
import { join } from "node:path"
import type { Event } from "electron"
@@ -345,7 +343,6 @@ function wireMenu() {
}
registerIpcHandlers({
httpFetch: (input) => bridgedHttpFetch(input),
killSidecar: () => killSidecar(),
relaunch: () => relaunchApp(),
awaitInitialization: async (sendStep) => {
@@ -409,104 +406,6 @@ function relaunchApp() {
app.exit(0)
}
// Uses node http clients directly rather than global fetch (undici). On Windows,
// undici pools keep-alive sockets across requests; the WSL2 port proxy
// silently drops idle loopback sockets, so reusing one hangs until timeout.
// `agent: false` + `Connection: close` forces a fresh TCP connection per
// request, which is the only reliable way to hit a WSL-forwarded port.
const BRIDGED_HTTP_METHODS = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"])
const MAX_BRIDGED_HTTP_BODY_BYTES = 25 * 1024 * 1024
function bridgedHttpFetch(
input: {
url: string
method: string
headers: Record<string, string>
body?: string
timeoutMs?: number
},
): Promise<{
status: number
statusText: string
headers: Record<string, string>
body: string
}> {
return new Promise((resolve, reject) => {
let parsed: URL
try {
parsed = new URL(input.url)
} catch (error) {
reject(new Error(`httpFetch: invalid url ${input.url}: ${String(error)}`))
return
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
reject(new Error(`httpFetch: only http: and https: are supported (got ${parsed.protocol})`))
return
}
const method = input.method.toUpperCase()
if (!BRIDGED_HTTP_METHODS.has(method)) {
reject(new Error(`httpFetch: unsupported method ${input.method}`))
return
}
if (input.body && Buffer.byteLength(input.body) > MAX_BRIDGED_HTTP_BODY_BYTES) {
reject(new Error(`httpFetch: request body exceeded ${MAX_BRIDGED_HTTP_BODY_BYTES} bytes`))
return
}
const req = (parsed.protocol === "https:" ? nodeHttps : nodeHttp).request({
host: parsed.hostname,
port: parsed.port ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80,
path: `${parsed.pathname}${parsed.search}`,
method,
headers: { ...input.headers, connection: "close" },
agent: false,
})
const timeoutMs = input.timeoutMs ?? 15_000
req.setTimeout(timeoutMs, () => {
req.destroy(new Error(`httpFetch: timeout after ${timeoutMs}ms (${input.method} ${input.url})`))
})
req.once("error", (error) => {
const err = error as NodeJS.ErrnoException
const detail = [err.name, err.code, err.message].filter(Boolean).join(" | ")
reject(new Error(`httpFetch: ${detail || "unknown error"}`))
})
req.once("response", (res) => {
const chunks: Buffer[] = []
let bytes = 0
res.on("data", (chunk: Buffer) => {
bytes += chunk.length
if (bytes <= MAX_BRIDGED_HTTP_BODY_BYTES) {
chunks.push(chunk)
return
}
res.destroy(new Error(`httpFetch: response exceeded ${MAX_BRIDGED_HTTP_BODY_BYTES} bytes`))
})
res.once("end", () => {
const headers: Record<string, string> = {}
for (const [key, value] of Object.entries(res.headers)) {
if (value === undefined) continue
headers[key] = Array.isArray(value) ? value.join(", ") : String(value)
}
resolve({
status: res.statusCode ?? 0,
statusText: res.statusMessage ?? "",
headers,
body: Buffer.concat(chunks).toString("utf8"),
})
})
res.once("error", (error) => {
reject(new Error(`httpFetch response error: ${String(error)}`))
})
})
if (input.body !== undefined) req.write(input.body)
req.end()
})
}
function ensureLoopbackNoProxy() {
const loopback = ["127.0.0.1", "localhost", "::1"]
const upsert = (key: string) => {

View File

@@ -22,18 +22,6 @@ const pickerFilters = (ext?: string[]) => {
}
type Deps = {
httpFetch: (input: {
url: string
method: string
headers: Record<string, string>
body?: string
timeoutMs?: number
}) => Promise<{
status: number
statusText: string
headers: Record<string, string>
body: string
}>
killSidecar: () => void
relaunch: () => void
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
@@ -130,13 +118,6 @@ export function registerIpcHandlers(deps: Deps) {
wslSubscriptions.clear()
})
handle(
"http-fetch",
(
_event: IpcMainInvokeEvent,
input: { url: string; method: string; headers: Record<string, string>; body?: string; timeoutMs?: number },
) => deps.httpFetch(input),
)
handle("kill-sidecar", () => deps.killSidecar())
handle("await-initialization", (event: IpcMainInvokeEvent) => {
const send = (step: InitStep) => event.sender.send("init-step", step)

View File

@@ -1,126 +0,0 @@
/** @ts-expect-error */
import * as pty from "@lydell/node-pty"
import type { RunWslOptions, WslCommandResult } from "./wsl"
export function runInteractiveCommand(
command: string,
args: string[],
opts: RunWslOptions = {},
defaultTimeoutMs: number,
) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = pty.spawn(command, args, {
name: "xterm-color",
cols: 80,
rows: 24,
cwd: process.cwd(),
env: process.env,
useConpty: true,
})
let settled = false
const parser = createInteractiveOutputParser((text) => opts.onLine?.({ stream: "stdout", text }))
let stdout = ""
const cleanup = () => {
clearTimeout(timeoutId)
abortCleanup?.()
parser.flush()
}
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs
const timeoutId = setTimeout(() => {
try {
child.kill()
} catch {
/* ignore */
}
if (settled) return
settled = true
cleanup()
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
}, timeoutMs)
const abortHandler = () => {
try {
child.kill()
} catch {
/* ignore */
}
if (settled) return
settled = true
cleanup()
reject(new DOMException("Aborted", "AbortError"))
}
const abortCleanup = opts.signal
? (() => {
opts.signal?.addEventListener("abort", abortHandler, { once: true })
return () => opts.signal?.removeEventListener("abort", abortHandler)
})()
: undefined
child.onData((data: string) => {
stdout += data
parser.write(data)
})
child.onExit((event: { exitCode: number }) => {
if (settled) return
settled = true
cleanup()
resolve({ code: event.exitCode, signal: null, stdout, stderr: "" })
})
})
}
function createInteractiveOutputParser(onLine: (line: string) => void) {
let line = ""
let escape = ""
let lastProgress = ""
const emit = (value: string) => {
const text = value.trim()
if (!text) return
if (/(\d{1,3}(?:[.,]\d+)?)\s*%/.test(text)) {
if (text === lastProgress) return
lastProgress = text
}
onLine(text)
}
return {
write(chunk: string) {
for (const char of chunk) {
if (escape) {
escape += char
const isCsi = escape.startsWith("\u001b[")
const isOsc = escape.startsWith("\u001b]")
if ((isCsi && /[@-~]/.test(char)) || (isOsc && char === "\u0007") || escape.endsWith("\u001b\\")) {
escape = ""
} else if (!isCsi && !isOsc && escape.length > 1) {
escape = ""
}
continue
}
if (char === "\u001b") {
escape = "\u001b"
continue
}
if (char === "\b" || char === "\u007f") {
line = line.slice(0, -1)
continue
}
if (char === "\r" || char === "\n") {
emit(line)
line = ""
continue
}
line += char
if (/(\d{1,3}(?:[.,]\d+)?)\s*%/.test(line)) emit(line)
}
},
flush() {
emit(line)
line = ""
},
}
}

View File

@@ -11,11 +11,9 @@ import type {
WslServerRuntime,
WslServersEvent,
WslServersState,
WslTranscriptLine,
} from "../preload/types"
import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants"
import { getStore } from "./store"
import type { WslCommandLine } from "./wsl"
import {
installWslDistro,
installWslOpencode,
@@ -63,27 +61,11 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
for (const listener of listeners) listener({ type: "state", state })
}
const isProgressLine = (text: string) => {
return text.includes("[") && text.includes("]") && /(\d{1,3}(?:[.,]\d+)?)\s*%/.test(text)
}
const setState = (next: Partial<WslServersState>) => {
state = { ...state, ...next }
emit()
}
const appendTranscript = (line: Omit<WslTranscriptLine, "at">) => {
const next = { ...line, at: Date.now() }
const last = state.transcript.at(-1)
if (last && last.stream === line.stream && isProgressLine(last.text) && isProgressLine(line.text)) {
setState({ transcript: [...state.transcript.slice(0, -1), next] })
return
}
setState({ transcript: [...state.transcript, next] })
}
const clearTranscript = () => setState({ transcript: [] })
const persistServers = (servers: WslServerConfig[]) => {
getStore().set(WSL_SERVERS_KEY, { servers })
}
@@ -93,23 +75,20 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
setState({ servers: next })
}
const beginJob = (job: WslJob, opts: { keepTranscript?: boolean } = {}): AbortController => {
const beginJob = (job: WslJob): AbortController => {
jobAbort?.abort()
const abort = new AbortController()
jobAbort = abort
if (!opts.keepTranscript) clearTranscript()
setState({ job, lastError: null })
setState({ job })
return abort
}
const endJob = (abort: AbortController, error?: Error | null) => {
const endJob = (abort: AbortController) => {
if (jobAbort !== abort) return
jobAbort = undefined
setState({ job: null, lastError: error?.message ?? null })
setState({ job: null })
}
const onLine = (line: WslCommandLine) => appendTranscript(line)
const refreshFromStore = () => {
const persisted = readPersistedServers()
const items: WslServerItem[] = persisted.map((config) => {
@@ -126,12 +105,6 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
updateServer(id, (item) => ({ ...item, runtime }))
}
const removeMissingServer = (id: string) => {
const remaining = readPersistedServers().filter((item) => item.id !== id)
persistServers(remaining)
setState({ servers: state.servers.filter((item) => item.config.id !== id) })
}
const setOpencodeCheck = (distro: string, check: WslOpencodeCheck) => {
setState({
opencodeChecks: {
@@ -141,16 +114,13 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
})
}
const refreshOpencodeCheck = async (
distro: string,
opts?: { signal?: AbortSignal; onLine?: (line: WslCommandLine) => void },
) => {
const refreshOpencodeCheck = async (distro: string, opts?: { signal?: AbortSignal }) => {
const resolved = await resolveWslOpencode(distro, opts)
const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
}
const refreshDistroLists = async (opts: { signal?: AbortSignal; onLine?: (line: WslCommandLine) => void }) => {
const refreshDistroLists = async (opts: { signal?: AbortSignal }) => {
const [installedResult, onlineResult] = await Promise.allSettled([
listInstalledWslDistros(opts),
listOnlineWslDistros(opts),
@@ -215,11 +185,6 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (!isCurrentStartAttempt(id, attempt)) return
if (isMissingDistroError(message)) {
removeMissingServer(id)
logger?.error("wsl server removed after missing distro", { id, distro: item.config.distro, message })
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
@@ -251,7 +216,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
return undefined
}
const err = error instanceof Error ? error : new Error(String(error))
endJob(abort, err)
endJob(abort)
throw err
}
}
@@ -272,8 +237,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
async probeRuntime() {
await runJob({ kind: "runtime", startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: "Checking WSL runtime" })
const runtime = await probeWslRuntime({ signal: abort.signal, onLine })
const runtime = await probeWslRuntime({ signal: abort.signal })
setState({
runtime,
pendingRestart: state.pendingRestart && !runtime.available ? state.pendingRestart : false,
@@ -283,15 +247,13 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
async refreshDistros() {
await runJob({ kind: "distros", startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: "Listing WSL distros" })
setState(await refreshDistroLists({ signal: abort.signal, onLine }))
setState(await refreshDistroLists({ signal: abort.signal }))
})
},
async installWsl() {
await runJob({ kind: "install-wsl", startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: "Installing WSL runtime" })
const result = await installWslRuntimeElevated({ signal: abort.signal, onLine })
const result = await installWslRuntimeElevated({ signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || "WSL installation failed"
throw new Error(message)
@@ -299,7 +261,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
const pendingRestart = wslNeedsRestart(result)
setState({ pendingRestart })
if (!pendingRestart) {
const runtime = await probeWslRuntime({ signal: abort.signal, onLine })
const runtime = await probeWslRuntime({ signal: abort.signal })
setState({ runtime })
}
})
@@ -307,14 +269,13 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
async installDistro(name: string) {
await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: `Installing WSL distro: ${name}` })
const result = await installWslDistro(name, { signal: abort.signal, onLine })
const result = await installWslDistro(name, { signal: abort.signal })
if (result.code !== 0) {
const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}`
throw new Error(message)
}
const distros = await refreshDistroLists({ signal: abort.signal, onLine })
const probe = await probeWslDistro(name, { signal: abort.signal, onLine })
const distros = await refreshDistroLists({ signal: abort.signal })
const probe = await probeWslDistro(name, { signal: abort.signal })
setState({
...distros,
distroProbes: { ...state.distroProbes, [name]: probe },
@@ -324,34 +285,31 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
async probeDistro(name: string) {
await runJob({ kind: "probe-distro", distro: name, startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: `Checking ${name}` })
const probe = await probeWslDistro(name, { signal: abort.signal, onLine })
const probe = await probeWslDistro(name, { signal: abort.signal })
setState({ distroProbes: { ...state.distroProbes, [name]: probe } })
})
},
async probeOpencode(name: string) {
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: `Checking OpenCode in ${name}` })
await refreshOpencodeCheck(name, { signal: abort.signal, onLine })
await refreshOpencodeCheck(name, { signal: abort.signal })
})
},
async installOpencode(name: string) {
await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
appendTranscript({ stream: "system", text: `Installing OpenCode in ${name}` })
const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine })
const resolved = await resolveWslOpencode(name, { signal: abort.signal })
const existingVersion = resolved
? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine })
? await readWslCommandVersion(resolved, name, { signal: abort.signal })
: null
const result =
resolved && existingVersion
? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal, onLine })
: await installWslOpencode(appVersion, name, { signal: abort.signal, onLine })
? await upgradeWslOpencode(appVersion, resolved, name, { signal: abort.signal })
: await installWslOpencode(appVersion, name, { signal: abort.signal })
if (result.code !== 0) {
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
}
await refreshOpencodeCheck(name, { signal: abort.signal, onLine })
await refreshOpencodeCheck(name, { signal: abort.signal })
})
},
@@ -362,7 +320,6 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
async cancelJob() {
jobAbort?.abort()
jobAbort = undefined
appendTranscript({ stream: "system", text: "Canceled" })
setState({ job: null })
},
@@ -433,8 +390,6 @@ function initialState(): WslServersState {
pendingRestart: false,
servers: [],
job: null,
transcript: [],
lastError: null,
}
}
@@ -531,10 +486,6 @@ function opencodeCheck(
}
}
function isMissingDistroError(message: string) {
return /WSL_E_DISTRO_NOT_FOUND|There is no distribution with the supplied name/i.test(message)
}
function startupFailure(code: number | null, signal: NodeJS.Signals | null) {
return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
}

View File

@@ -1,8 +1,9 @@
import { spawn } from "node:child_process"
import { existsSync } from "node:fs"
import { join } from "node:path"
/** @ts-expect-error */
import * as pty from "@lydell/node-pty"
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types"
import { runInteractiveCommand } from "./wsl-pty"
export type WslCommandLine = {
stream: "stdout" | "stderr"
@@ -17,7 +18,6 @@ export type WslCommandResult = {
}
export type RunWslOptions = {
onLine?: (line: WslCommandLine) => void
signal?: AbortSignal
/**
* Ceiling on how long we wait for the child process to exit. When the
@@ -82,48 +82,16 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
let stdout = ""
let stderr = ""
let stdoutPending = ""
let stderrPending = ""
const stdoutDecoder = createOutputDecoder()
const stderrDecoder = createOutputDecoder()
const flush = (stream: WslCommandLine["stream"], pending: string) => {
if (!pending) return ""
opts.onLine?.({ stream, text: pending })
return ""
}
const splitOutput = (pending: string) => {
const lines: string[] = []
let start = 0
for (let i = 0; i < pending.length; i++) {
const char = pending[i]
if (char !== "\r" && char !== "\n") continue
lines.push(pending.slice(start, i))
if (char === "\r" && pending[i + 1] === "\n") i += 1
start = i + 1
}
return {
lines,
pending: pending.slice(start),
}
}
const append = (stream: WslCommandLine["stream"], chunk: string) => {
if (!chunk) return
if (stream === "stdout") {
stdout += chunk
stdoutPending += chunk
const next = splitOutput(stdoutPending)
stdoutPending = next.pending
for (const line of next.lines) opts.onLine?.({ stream: "stdout", text: line })
return
}
stderr += chunk
stderrPending += chunk
const next = splitOutput(stderrPending)
stderrPending = next.pending
for (const line of next.lines) opts.onLine?.({ stream: "stderr", text: line })
}
child.stdout.on("data", (chunk: Buffer) => {
@@ -131,7 +99,6 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
})
child.stdout.on("end", () => {
append("stdout", stdoutDecoder.flush())
stdoutPending = flush("stdout", stdoutPending)
})
child.stderr.on("data", (chunk: Buffer) => {
@@ -139,7 +106,6 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
})
child.stderr.on("end", () => {
append("stderr", stderrDecoder.flush())
stderrPending = flush("stderr", stderrPending)
})
child.once("error", (error) => {
@@ -153,6 +119,68 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
})
}
function runInteractiveCommand(command: string, args: string[], opts: RunWslOptions = {}, defaultTimeoutMs: number) {
return new Promise<WslCommandResult>((resolve, reject) => {
const child = pty.spawn(command, args, {
name: "xterm-color",
cols: 80,
rows: 24,
cwd: process.cwd(),
env: process.env,
useConpty: true,
})
let settled = false
let stdout = ""
const cleanup = () => {
clearTimeout(timeoutId)
abortCleanup?.()
}
const timeoutMs = opts.timeoutMs ?? defaultTimeoutMs
const timeoutId = setTimeout(() => {
try {
child.kill()
} catch {
/* ignore */
}
if (settled) return
settled = true
cleanup()
reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`))
}, timeoutMs)
const abortHandler = () => {
try {
child.kill()
} catch {
/* ignore */
}
if (settled) return
settled = true
cleanup()
reject(new DOMException("Aborted", "AbortError"))
}
const abortCleanup = opts.signal
? (() => {
opts.signal?.addEventListener("abort", abortHandler, { once: true })
return () => opts.signal?.removeEventListener("abort", abortHandler)
})()
: undefined
child.onData((data: string) => {
stdout += data
})
child.onExit((event: { exitCode: number }) => {
if (settled) return
settled = true
cleanup()
resolve({ code: event.exitCode, signal: null, stdout, stderr: "" })
})
})
}
function createOutputDecoder() {
let decoder: TextDecoder | undefined
return {
@@ -211,10 +239,6 @@ export async function readWslDistrosFromRegistry(opts?: RunWslOptions): Promise<
)
const stdout = result.stdout
if (result.code !== 0 || !stdout) {
;(opts?.onLine ?? (() => undefined))({
stream: "stderr",
text: `reg query failed code=${result.code} stderr=${result.stderr.slice(0, 200)}`,
})
return []
}
const blocks = stdout.split(/\r?\n\r?\n/)

View File

@@ -2,7 +2,6 @@ import { contextBridge, ipcRenderer } from "electron"
import type { ElectronAPI, InitStep, SqliteMigrationProgress, WslServersEvent } from "./types"
const api: ElectronAPI = {
httpFetch: (input) => ipcRenderer.invoke("http-fetch", input),
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
installCli: () => ipcRenderer.invoke("install-cli"),
awaitInitialization: (onStep) => {

View File

@@ -47,12 +47,6 @@ export type WslOpencodeCheck = {
matchesDesktop: boolean | null
error: string | null
}
export type WslTranscriptLine = {
stream: "stdout" | "stderr" | "system"
text: string
at: number
}
export type WslServerAcknowledgements = {
root: boolean
mismatch: { path: string; version: string } | null
@@ -93,8 +87,6 @@ export type WslServersState = {
pendingRestart: boolean
servers: WslServerItem[]
job: WslJob | null
transcript: WslTranscriptLine[]
lastError: string | null
}
export type WslServersEvent = { type: "state"; state: WslServersState }
@@ -127,18 +119,6 @@ export type WindowConfig = {
}
export type ElectronAPI = {
httpFetch: (input: {
url: string
method: string
headers: Record<string, string>
body?: string
timeoutMs?: number
}) => Promise<{
status: number
statusText: string
headers: Record<string, string>
body: string
}>
killSidecar: () => Promise<void>
installCli: () => Promise<string>
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>

View File

@@ -138,48 +138,6 @@ const createPlatform = (): Platform => {
return window.api.wslPath("~", "windows", distro).catch(() => undefined)
}
// SSE endpoints must keep a live connection; IPC-bridged fetch buffers the
// whole response body in main before returning, which breaks streams.
const isStreamingPath = (pathname: string) =>
pathname.endsWith("/event") || pathname === "/global/event" || pathname.endsWith("/pty/read")
// Chromium's network stack on Windows frequently stalls on WSL2-forwarded
// loopback ports (happy-eyeballs to [::1] hits the WSL port proxy which
// only binds v4). Node/undici in main has no such issue, so we route WSL
// loopback requests through the main process. `localhost`/`[::1]` are also
// loopback spellings we need to catch.
const isLoopback = (hostname: string) =>
hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]"
const shouldBridge = (url: URL) => {
if (!activeWslDistro()) return false
if (url.protocol !== "http:") return false
if (!isLoopback(url.hostname)) return false
if (isStreamingPath(url.pathname)) return false
return true
}
const bridgedFetch = async (request: Request, timeoutMs?: number) => {
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.clone().text()
const res = await window.api.httpFetch({
url: request.url,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
body,
timeoutMs,
})
// Null-body statuses (101/204/205/304) must be constructed with a null
// body or the Response constructor throws `Response with null body
// status cannot have body`. The IPC layer always hands us `res.body` as
// a string, so coerce to null for these statuses.
const nullBody = res.status === 101 || res.status === 204 || res.status === 205 || res.status === 304
return new Response(nullBody ? null : res.body, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
})
}
const handleWslPicker = async <T extends string | string[] | null>(result: T): Promise<T> => {
const distro = activeWslDistro()
if (!result || !distro) return result
@@ -314,49 +272,7 @@ const createPlatform = (): Platform => {
}
},
fetch: (input, init) => {
const request = input instanceof Request ? (init ? new Request(input, init) : input) : new Request(input, init)
const url = (() => {
try {
return new URL(request.url, location.href)
} catch {
return null
}
})()
if (!url || !shouldBridge(url)) {
if (input instanceof Request && !init) return fetch(input)
return fetch(request)
}
// Propagate the request's own abort signal to the bridge via a finite
// timeout. If nothing set one we default to 15s so connects can't hang
// forever waiting on a dead WSL port proxy.
const signal = request.signal
const timeoutMs = 15_000
return new Promise<Response>((resolve, reject) => {
let settled = false
const onAbort = () => {
if (settled) return
settled = true
reject(new DOMException("Aborted", "AbortError"))
}
if (signal?.aborted) return onAbort()
signal?.addEventListener("abort", onAbort, { once: true })
bridgedFetch(request, timeoutMs).then(
(res) => {
if (settled) return
settled = true
signal?.removeEventListener("abort", onAbort)
resolve(res)
},
(err) => {
if (settled) return
settled = true
signal?.removeEventListener("abort", onAbort)
reject(err)
},
)
})
},
fetch,
getDefaultServer: async () => {
const url = await window.api.getDefaultServerUrl().catch(() => null)
@@ -438,16 +354,6 @@ render(() => {
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [locale] = createResource(loadLocale)
const [storedServers] = createResource(async () => {
const raw = await platform.storage?.("opencode.global.dat").getItem("server")
if (!raw) return []
try {
const parsed = JSON.parse(raw) as { list?: unknown }
return Array.isArray(parsed.list) ? parsed.list : []
} catch {
return []
}
})
const [wslServers, setWslServers] = createSignal<WslServersState | undefined>()
const [wslReady, setWslReady] = createSignal(!platform.wslServers)
if (platform.wslServers) {
@@ -524,30 +430,23 @@ render(() => {
}
for (const item of wslServers()?.servers ?? []) {
const runtime = item.runtime
if (runtime.kind !== "ready") continue
list.push({
displayName: item.config.distro,
type: "sidecar",
variant: "wsl",
distro: item.config.distro,
http:
runtime.kind === "ready"
? {
url: runtime.url,
username: runtime.username ?? undefined,
password: runtime.password ?? undefined,
}
: { url: `http://wsl-${item.config.distro}.invalid` },
http: {
url: runtime.url,
username: runtime.username ?? undefined,
password: runtime.password ?? undefined,
},
})
}
return list
})
const hasFallbackServers = createMemo(() => {
if ((storedServers.latest?.length ?? 0) > 0) return true
return (wslServers()?.servers.length ?? 0) > 0
})
if (!ready()) return splash
if (startup.latest?.error && !storedServers.loading && !hasFallbackServers()) {
if (startup.latest?.error) {
return <LocalServerStartupError message={startup.latest.error} />
}

View File

@@ -12,9 +12,6 @@ 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) {
@@ -42,9 +39,6 @@ export function Dialog(props: DialogProps) {
autofocusEl.focus()
}
}}
onInteractOutside={(e) => {
if (props.dismissOutside === false) e.preventDefault()
}}
>
<Show when={props.title || props.action}>
<div data-slot="dialog-header">

64
todo.md Normal file
View File

@@ -0,0 +1,64 @@
# PR Simplification TODO
- [x] [High][S] Remove fake WSL `.invalid` server entries from `packages/desktop-electron/src/renderer/index.tsx`; only include ready WSL servers in `servers()`.
- [x] [High][S] Delete `isPlaceholderServerUrl` usage/guards after removing fake WSL servers: `server-health.ts`, `context/server.tsx`, `dialog-select-server.tsx`, `status-popover-body.tsx`, `app.tsx`.
- [x] [High][S] Remove destructive missing-distro auto-delete in `packages/desktop-electron/src/main/wsl-servers.ts`; mark runtime failed and let the user remove it manually.
- [ ] ~~[High][S] Revisit `versionOlderThan()` in `dialog-select-server.tsx`; keep newer-vs-older semantics or move comparison to backend state.~~ (Skipped for now)
- [x] [High][S] Remove `dismissOutside` shared UI prop and usage; it is one-off and incomplete because Escape still closes globally.
- [x] [High][S] Remove `cachedServerStatus` from `dialog-select-server.tsx`; `useCheckServerHealth()` already caches and this cache can go stale across credential edits.
- [x] [High][S] Remove `pendingSelectKey` WSL selection sync from `dialog-select-server.tsx`; select immediately if present or close/let list refresh.
- [x] [High][S] Delete `withWslApi()` silent fallback in `dialog-wsl-server.tsx`; only mount the dialog when `platform.wslServers` exists.
- [x] [High][M] Remove the 8-9 `useMutation` pass-through wrappers in `dialog-wsl-server.tsx`; use the existing `run()` helper and direct API calls.
- [ ] ~~[High][M] Remove `autoProbe` / `lastAutoProbe` state machine from `dialog-wsl-server.tsx` unless a repro proves automatic probing is required.~~ (Skipped for now: wizard currently relies on automatic initial probes)
- [x] [High][M] Remove Electron renderer `storedServers` / `hasFallbackServers` raw storage parsing fallback in `packages/desktop-electron/src/renderer/index.tsx`.
- [x] [High][M] Decide whether to delete the Electron `httpFetch` bridge entirely and use normal fetch; current bridge duplicates fetch semantics and adds IPC surface.
- [x] [High][M] Remove terminal restore preflight (`pty.get()` before websocket) in `components/terminal.tsx`; it duplicates the existing clone-on-connect-error path.
- [x] [High][M] Remove server-key from in-memory terminal cache in `context/terminal.tsx`; `TerminalProvider` is already under keyed `ServerKey` remount.
- [x] [High][M] Remove deferred terminal provider `disposeAll()` macrotask workaround unless a minimal repro proves it is still needed.
- [x] [High][M] Remove `unsupportedWorkspace` null-object fallback for WSL `/mnt/` in `context/terminal.tsx`; block earlier or let PTY failure surface.
- [x] [High][M] Fold or delete `packages/desktop-electron/src/main/wsl-pty.ts`; it has one importer and can live in `wsl.ts` if kept.
- [ ] [High][L] Remove duplicated WSL state subscription in Electron renderer or `WslServersProvider`; keep one WSL state owner.
- [ ] [High][L] Revisit `DefaultServer` context; fold into server ownership or make it a real provider instead of ad hoc query hooks.
- [ ] [High][L] Collapse duplicated server health loops/logging across `ConnectionGate`, `ServerProvider`, dialog, and status popover.
- [ ] [Medium][S] Remove `initialView?: "add-wsl"` from `DialogSelectServer` if no callsites need it.
- [ ] [Medium][S] Remove `handleRemoveWsl`, `handleRetryWsl`, `handleUpdateWsl` guard helpers; callers already know when item is WSL.
- [ ] [Medium][S] Remove `hasMenuActionsBeforeDelete()` from `dialog-select-server.tsx`; inline or always render the separator in the delete block.
- [x] [Medium][S] Remove UI-only `parseProgressPercent()` and percent display in WSL wizard unless progress is structured by backend.
- [ ] [Medium][S] Remove hard-coded Ubuntu installable distro special-case from UI; backend/platform should return installable distros.
- [ ] [Medium][S] Remove `isHiddenDistro()` UI filter for `docker-desktop`; backend/platform should decide hidden distros if needed.
- [x] [Medium][S] Remove `installProgress()` transcript shaping in WSL wizard; render raw transcript or structured progress.
- [ ] [Medium][S] Remove `stepIndex` / `stepTitle` / `stepState` helpers in WSL wizard; inline the 3-step UI state.
- [ ] [Medium][S] Remove `runWslBash()` from `wsl.ts`; it is unused.
- [x] [Medium][S] Remove progress-line de-duping from either `wsl-pty.ts` or `wsl-servers.ts`; keep only one place if kept.
- [ ] [Medium][S] Remove `ensureLoopbackNoProxy()` and Chromium proxy-bypass mutation unless a measured proxy repro exists.
- [ ] [Medium][S] Remove broad renderer global error/rejection logging in Electron renderer unless this PR intentionally adds diagnostics.
- [ ] [Medium][S] Remove `wireWindowDiagnostics()` or move it to a separate diagnostics PR; it is unrelated WSL feature plumbing.
- [ ] [Medium][S] Remove terminal debug logging helpers/calls once current investigation is done.
- [ ] [Medium][S] Remove server-health retry logging helpers (`serializeError`, `stringifyLog`, per-attempt logs`) or dev-gate one final warning.
- [ ] [Medium][S] Remove `resolveSystem32Command()` if no PATH failure is proven; call `wsl.exe` directly.
- [ ] [Medium][S] Remove WSL path picker catch fallbacks in Electron renderer; for active WSL, failed conversion should fail, not pass Windows paths through.
- [ ] [Medium][M] Remove `createOutputDecoder()` / `detectOutputEncoding()` heuristic unless logs prove UTF-16 WSL output occurs.
- [ ] [Medium][M] Remove registry parsing/default-user discovery in `wsl.ts` if not proven necessary; use the actual WSL command context consistently.
- [ ] [Medium][M] Simplify `resolveWslOpencode()` fallback path search; prefer `command -v opencode` unless installer/PATH proves otherwise.
- [ ] [Medium][M] Simplify WSL sidecar shell script env/path/watchman workarounds in `server.ts`; add back only with repro/logs.
- [ ] [Medium][M] Remove `startAttempts` stale-start guard in WSL controller unless start/remove/stop race is reproduced.
- [ ] [Medium][M] Remove `acknowledgements` model/API/IPC if UI does not consume it.
- [ ] [Medium][M] Remove `Promise.allSettled` distro-list fallback; let listing errors surface instead of showing empty distros.
- [ ] [Medium][M] Collapse WSL persisted config + state mutations into one owner/path, or derive runtime state instead of mutating both.
- [ ] [Medium][M] Remove bespoke WSL subscribe/unsubscribe IPC lifecycle if broadcast event pattern is acceptable.
- [ ] [Medium][M] Collapse repeated WSL IPC method lists in `ipc.ts`, `index.ts`, and preload into one simpler mapping or smaller API surface.
- [ ] [Medium][M] Remove duplicated health polling/sorting between status popover and server dialog; keep one or let dialog own detailed health.
- [ ] [Medium][M] Remove duplicated server-switch navigation/batching from status popover, dialog, and connection error; keep one simple path.
- [ ] [Low][S] Revert dialog timer/lock refactor if it was style churn unrelated to behavior.
- [ ] [Low][S] Revert global dialog CSS overflow changes unless required by a specific dialog.
- [ ] [Low][S] Inline `nextActiveKey()` in `context/server.tsx` unless more callers appear.
- [ ] [Low][S] Add `ServerConnection.isWsl(conn)` only if it deletes more local type guards than it adds; otherwise keep local checks.
- [ ] [Low][S] Replace manual `wsl:${distro}` key construction with returned config id or existing `ServerConnection.key` only where already touching code.
- [ ] [Low][S] Remove duplicate line-forwarding helper in `server.ts` or reuse existing WSL output handling.
- [ ] [Low][S] Remove `pickerFilters()` duplication only if there is an existing shared helper safe for Electron main.
- [ ] [Low][M] Remove duplicate top-level fields from `ServerReadyData` if Electron/Tauri compatibility allows it.
- [ ] [Low][M] Consider moving WSL wizard out of embedded server-select mode; current `showWizard`/fit/body/dismiss state duplicates dialog flow.
- [ ] [Low][L] Consider deleting/folding `context/wsl-servers.tsx` if WSL state stays owned by Electron renderer or server context.
- [ ] [Low][L] Consider deleting/folding `context/default-server.tsx` if default server becomes part of `useServer()`.