mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-21 19:35:10 +00:00
wip
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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_))
|
||||
|
||||
@@ -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__")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = ""
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"})`
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
|
||||
@@ -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
64
todo.md
Normal 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()`.
|
||||
Reference in New Issue
Block a user