feedback n stuff?
This commit is contained in:
@@ -13,11 +13,36 @@ import { createStore, reconcile } from "solid-js/store"
|
||||
import { DialogWslServer } from "@/components/dialog-wsl-server"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { WslServersState } from "@/context/platform"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
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
|
||||
|
||||
const parse = (value: string) => {
|
||||
const match = value.match(/v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
|
||||
if (!match) return
|
||||
return {
|
||||
major: Number(match[1]),
|
||||
minor: Number(match[2]),
|
||||
patch: Number(match[3]),
|
||||
prerelease: match[4] ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
const left = parse(current)
|
||||
const right = parse(expected)
|
||||
if (!left || !right) return false
|
||||
if (left.major !== right.major) return left.major < right.major
|
||||
if (left.minor !== right.minor) return left.minor < right.minor
|
||||
if (left.patch !== right.patch) return left.patch < right.patch
|
||||
return !!left.prerelease && !right.prerelease
|
||||
}
|
||||
|
||||
interface DialogSelectServerProps {
|
||||
initialView?: "list" | "add-wsl"
|
||||
@@ -186,6 +211,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
wslState: undefined as WslServersState | undefined,
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
@@ -319,6 +345,14 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
})
|
||||
|
||||
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
|
||||
const healthPollKey = createMemo(() =>
|
||||
items()
|
||||
.map((conn) =>
|
||||
[ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n"),
|
||||
)
|
||||
.join("\n\n"),
|
||||
)
|
||||
const health = (key: ServerConnection.Key) => store.status[key] ?? cachedServerStatus.get(key)
|
||||
|
||||
const sortedItems = createMemo(() => {
|
||||
const list = items()
|
||||
@@ -333,7 +367,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
return list.slice().sort((a, b) => {
|
||||
if (a === active) return -1
|
||||
if (b === active) return 1
|
||||
const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)])
|
||||
const diff = rank(health(ServerConnection.key(a))) - rank(health(ServerConnection.key(b)))
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
@@ -346,27 +380,74 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
for (const [key, value] of Object.entries(results)) {
|
||||
cachedServerStatus.set(ServerConnection.Key.make(key), value)
|
||||
}
|
||||
setStore("status", reconcile(results))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
items()
|
||||
healthPollKey()
|
||||
void refreshHealth()
|
||||
const interval = setInterval(refreshHealth, 10_000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const api = platform.wslServers
|
||||
if (!api) return
|
||||
let dead = false
|
||||
void api
|
||||
.getState()
|
||||
.then((state) => {
|
||||
if (dead) return
|
||||
setStore("wslState", reconcile(state))
|
||||
})
|
||||
.catch((err) => {
|
||||
if (dead) return
|
||||
showRequestError(language, err)
|
||||
})
|
||||
const off = api.subscribe((event) => {
|
||||
setStore("wslState", reconcile(event.state))
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
off()
|
||||
})
|
||||
})
|
||||
|
||||
const wslCheck = (conn: ServerConnection.Any) => {
|
||||
if (conn.type !== "sidecar" || conn.variant !== "wsl") return null
|
||||
return store.wslState?.opencodeChecks[conn.distro] ?? null
|
||||
}
|
||||
|
||||
const displayVersion = (conn: ServerConnection.Any) => {
|
||||
if (conn.type === "sidecar" && conn.variant === "wsl") return wslCheck(conn)?.version ?? undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||
dialog.close()
|
||||
const nextKey = ServerConnection.key(conn)
|
||||
const changed = server.key !== nextKey
|
||||
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
props.onNavigateHome?.()
|
||||
if (changed && typeof window !== "undefined" && window.history?.replaceState) {
|
||||
window.history.replaceState(null, "", "/")
|
||||
} else {
|
||||
props.onNavigateHome?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
batch(() => {
|
||||
props.onNavigateHome?.()
|
||||
server.setActive(ServerConnection.key(conn))
|
||||
if (changed && typeof window !== "undefined" && window.history?.replaceState) {
|
||||
window.history.replaceState(null, "", "/")
|
||||
} else {
|
||||
props.onNavigateHome?.()
|
||||
}
|
||||
server.setActive(nextKey)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -552,6 +633,18 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateWsl(conn: ServerConnection.Any) {
|
||||
if (conn.type !== "sidecar" || conn.variant !== "wsl") return
|
||||
const api = platform.wslServers
|
||||
if (!api) return
|
||||
try {
|
||||
await api.installOpencode(conn.distro)
|
||||
await refreshHealth()
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={formTitle()} dismissOutside={!isAddWslMode()}>
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -601,15 +694,32 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
{(i) => {
|
||||
const key = ServerConnection.key(i)
|
||||
const isWslSidecar = i.type === "sidecar" && i.variant === "wsl"
|
||||
const wslDistro = i.type === "sidecar" && i.variant === "wsl" ? i.distro : undefined
|
||||
const hasMenuActionsBeforeDelete = () =>
|
||||
i.type === "http" || (isWslSidecar && health(key)?.healthy === false)
|
||||
const outdated = () => {
|
||||
const check = wslCheck(i)
|
||||
return versionOlderThan(check?.version, check?.expectedVersion)
|
||||
}
|
||||
const opencodeAction = () => {
|
||||
const check = wslCheck(i)
|
||||
if (!check) return null
|
||||
if (!check.resolvedPath) return "Install OpenCode"
|
||||
if (outdated()) return "Update OpenCode"
|
||||
return null
|
||||
}
|
||||
const updating = () =>
|
||||
store.wslState?.job?.kind === "install-opencode" && store.wslState.job.distro === wslDistro
|
||||
return (
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1 w-full group/item">
|
||||
<div class="flex flex-col h-full items-start w-5">
|
||||
<ServerHealthIndicator health={store.status[key]} />
|
||||
<ServerHealthIndicator health={health(key)} />
|
||||
</div>
|
||||
<ServerRow
|
||||
conn={i}
|
||||
dimmed={store.status[key]?.healthy === false}
|
||||
status={store.status[key]}
|
||||
dimmed={health(key)?.healthy === false}
|
||||
status={health(key)}
|
||||
version={displayVersion(i)}
|
||||
class="flex items-center gap-3 min-w-0 flex-1"
|
||||
badge={
|
||||
<Show when={defaultKey() === ServerConnection.key(i)}>
|
||||
@@ -621,6 +731,23 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
showCredentials
|
||||
/>
|
||||
<div class="flex items-center justify-center gap-3 pl-4">
|
||||
<Show when={isWslSidecar && opencodeAction()}>
|
||||
{(label) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={!!store.wslState?.job}
|
||||
class="shrink-0"
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
void handleUpdateWsl(i)
|
||||
}}
|
||||
>
|
||||
{updating() ? "Updating OpenCode..." : label()}
|
||||
</Button>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={ServerConnection.key(current()) === key}>
|
||||
<Icon name="check" class="h-6" />
|
||||
</Show>
|
||||
@@ -647,7 +774,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
<DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={isWslSidecar && store.status[key]?.healthy === false}>
|
||||
<Show when={isWslSidecar && health(key)?.healthy === false}>
|
||||
<DropdownMenu.Item onSelect={() => void handleRetryWsl(i)}>
|
||||
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
@@ -666,8 +793,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={i.type === "http" || isWslSidecar}>
|
||||
<Show when={hasMenuActionsBeforeDelete()}>
|
||||
<DropdownMenu.Separator />
|
||||
</Show>
|
||||
<Show when={i.type === "http" || isWslSidecar}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
|
||||
@@ -10,6 +10,18 @@ import { usePlatform } from "@/context/platform"
|
||||
|
||||
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
|
||||
|
||||
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?: () => void
|
||||
}
|
||||
@@ -66,7 +78,11 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
if (!distro) return null
|
||||
return (current()?.installed ?? []).find((item) => item.name === distro) ?? null
|
||||
})
|
||||
const defaultInstalledDistro = createMemo(() => (current()?.installed ?? []).find((item) => item.isDefault) ?? null)
|
||||
const visibleInstalledDistros = createMemo(() =>
|
||||
(current()?.installed ?? []).filter((item) => !isHiddenDistro(item.name)),
|
||||
)
|
||||
const visibleOnlineDistros = createMemo(() => (current()?.online ?? []).filter((item) => !isHiddenDistro(item.name)))
|
||||
const defaultInstalledDistro = createMemo(() => visibleInstalledDistros().find((item) => item.isDefault) ?? null)
|
||||
const opencodeCheck = createMemo(() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return null
|
||||
@@ -97,17 +113,27 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
})
|
||||
const existingServerDistros = createMemo(() => new Set((current()?.servers ?? []).map((item) => item.config.distro)))
|
||||
const addableInstalledDistros = createMemo(() => {
|
||||
return (current()?.installed ?? []).filter((item) => !existingServerDistros().has(item.name))
|
||||
return visibleInstalledDistros().filter((item) => !existingServerDistros().has(item.name))
|
||||
})
|
||||
const installableDistros = createMemo(() => {
|
||||
const online = current()?.online ?? []
|
||||
const installed = new Set((current()?.installed ?? []).map((item) => item.name))
|
||||
const online = visibleOnlineDistros()
|
||||
const installed = new Set(visibleInstalledDistros().map((item) => item.name))
|
||||
const hasVersionedUbuntu = online.some((item) => /^Ubuntu-\d/.test(item.name))
|
||||
return online
|
||||
.filter((item) => !installed.has(item.name))
|
||||
.filter((item) => !(item.name === "Ubuntu" && hasVersionedUbuntu))
|
||||
})
|
||||
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()
|
||||
@@ -284,12 +310,12 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
return (
|
||||
<div class="px-5 pb-5 flex flex-col gap-4">
|
||||
<Show when={!store.loading} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
|
||||
<div class="flex gap-2 overflow-x-auto pb-1">
|
||||
<div class="flex gap-2 pb-1">
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
class="min-w-[132px] rounded-md border px-3 py-2 text-left transition-colors"
|
||||
class="basis-0 flex-1 min-w-0 rounded-md border px-3 py-2 text-left transition-colors"
|
||||
classList={{
|
||||
"border-border-strong-base bg-surface-base-hover": item.state === "current",
|
||||
"border-icon-success-base/40 bg-surface-base": item.state === "done",
|
||||
@@ -343,7 +369,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
when={addableInstalledDistros().length > 0}
|
||||
fallback={
|
||||
<div class="text-12-regular text-text-weak">
|
||||
{current()?.installed.length
|
||||
{visibleInstalledDistros().length
|
||||
? "All installed distros are already added."
|
||||
: current()?.runtime?.available
|
||||
? "No distros detected yet."
|
||||
@@ -375,14 +401,24 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
<div class="rounded-md border border-border-weak-base p-2 flex flex-col gap-2">
|
||||
<div class="px-1 flex items-center justify-between gap-3">
|
||||
<div class="text-12-medium text-text-weak">Install</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={busy() || !installTarget()}
|
||||
onClick={() => void run(() => wslServers()!.installDistro(installTarget()!.name))}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
<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>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={busy() || !installTarget()}
|
||||
onClick={() => void run(() => wslServers()!.installDistro(installTarget()!.name))}
|
||||
>
|
||||
{installingDistro() ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="radiogroup"
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { ServerHealth } from "@/utils/server-health"
|
||||
interface ServerRowProps extends ParentProps {
|
||||
conn: ServerConnection.Any
|
||||
status?: ServerHealth
|
||||
version?: string
|
||||
class?: string
|
||||
nameClass?: string
|
||||
versionClass?: string
|
||||
@@ -31,6 +32,8 @@ export function ServerRow(props: ServerRowProps) {
|
||||
let nameRef: HTMLSpanElement | undefined
|
||||
let versionRef: HTMLSpanElement | undefined
|
||||
const name = createMemo(() => serverName(props.conn))
|
||||
const isWsl = createMemo(() => props.conn.type === "sidecar" && props.conn.variant === "wsl")
|
||||
const version = createMemo(() => props.version ?? props.status?.version)
|
||||
|
||||
const check = () => {
|
||||
const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
|
||||
@@ -41,7 +44,7 @@ export function ServerRow(props: ServerRowProps) {
|
||||
createEffect(() => {
|
||||
name()
|
||||
props.conn.http.url
|
||||
props.status?.version
|
||||
version()
|
||||
queueMicrotask(check)
|
||||
})
|
||||
|
||||
@@ -54,8 +57,11 @@ export function ServerRow(props: ServerRowProps) {
|
||||
const tooltipValue = () => (
|
||||
<span class="flex items-center gap-2">
|
||||
<span>{serverName(props.conn, true)}</span>
|
||||
<Show when={props.status?.version}>
|
||||
<span class="text-text-invert-weak">v{props.status?.version}</span>
|
||||
<Show when={isWsl()}>
|
||||
<span class="text-text-invert-weak">WSL</span>
|
||||
</Show>
|
||||
<Show when={version()}>
|
||||
<span class="text-text-invert-weak">v{version()}</span>
|
||||
</Show>
|
||||
</span>
|
||||
)
|
||||
@@ -76,15 +82,20 @@ export function ServerRow(props: ServerRowProps) {
|
||||
<span ref={nameRef} class={`${props.nameClass ?? "truncate"} min-w-0`}>
|
||||
{name()}
|
||||
</span>
|
||||
<Show when={isWsl()}>
|
||||
<span class="text-11-regular text-text-weak border border-border-weak-base bg-surface-base px-1.5 py-0.5 rounded-md shrink-0">
|
||||
WSL
|
||||
</span>
|
||||
</Show>
|
||||
<Show
|
||||
when={badge()}
|
||||
fallback={
|
||||
<Show when={props.status?.version}>
|
||||
<Show when={version()}>
|
||||
<span
|
||||
ref={versionRef}
|
||||
class={`${props.versionClass ?? "text-text-weak text-14-regular truncate"} min-w-0`}
|
||||
>
|
||||
v{props.status?.version}
|
||||
v{version()}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
import { setServerSwitching } from "@/utils/server-switch"
|
||||
@@ -91,7 +91,7 @@ const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
url: undefined as string | undefined,
|
||||
key: undefined as ServerConnection.Key | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
@@ -100,7 +100,7 @@ const useDefaultServerKey = (
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("url", undefined)
|
||||
setState("key", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
@@ -110,7 +110,7 @@ const useDefaultServerKey = (
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||
setState("key", next ? ServerConnection.Key.make(next) : undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
@@ -118,18 +118,14 @@ const useDefaultServerKey = (
|
||||
return
|
||||
}
|
||||
|
||||
setState("url", normalizeServerUrl(result))
|
||||
setState("key", ServerConnection.Key.make(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
const u = state.url
|
||||
if (!u) return
|
||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||
},
|
||||
key: () => state.key,
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
@@ -306,7 +302,13 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
batch(() => {
|
||||
navigate("/")
|
||||
if (server.key !== key) {
|
||||
if (typeof window !== "undefined" && window.history?.replaceState) {
|
||||
window.history.replaceState(null, "", "/")
|
||||
}
|
||||
} else {
|
||||
navigate("/")
|
||||
}
|
||||
server.setActive(key)
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -74,6 +74,11 @@ const errorName = (err: unknown) => {
|
||||
return typeof errorName === "string" ? errorName : undefined
|
||||
}
|
||||
|
||||
const logTerminal = (phase: string, input: Record<string, unknown>) => {
|
||||
if (!import.meta.env.DEV) return
|
||||
console.log(`[terminal ui] ${JSON.stringify({ phase, ...input })}`)
|
||||
}
|
||||
|
||||
const useTerminalUiBindings = (input: {
|
||||
container: HTMLDivElement
|
||||
term: Term
|
||||
@@ -169,11 +174,11 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const server = useServer()
|
||||
const directory = sdk.directory
|
||||
const client = sdk.client
|
||||
const url = sdk.url
|
||||
const auth = server.current?.http
|
||||
const username = auth?.username ?? "opencode"
|
||||
const password = auth?.password ?? ""
|
||||
const sameOrigin = new URL(url, location.href).origin === location.origin
|
||||
const currentUrl = () => server.current?.http.url ?? sdk.url
|
||||
const sameOrigin = () => new URL(currentUrl(), location.href).origin === location.origin
|
||||
let container!: HTMLDivElement
|
||||
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
|
||||
const id = local.pty.id
|
||||
@@ -450,20 +455,32 @@ export const Terminal = (props: TerminalProps) => {
|
||||
output.flush(resolve)
|
||||
})
|
||||
|
||||
if (restore && restoreSize) {
|
||||
// Defer the serialised `restore` buffer until the WebSocket actually
|
||||
// opens against the live PTY. Previously we wrote it synchronously
|
||||
// before connect, which painted stale content on screen whenever the
|
||||
// sidecar had restarted (e.g. a server swap): every saved pty id
|
||||
// belongs to the old sidecar, so connect eventually fails and the
|
||||
// clone handler wipes the buffer — but you'd see the old bash/pwsh
|
||||
// scrollback flash first. Now `restore` is only applied once we know
|
||||
// the pty is real (handleOpen), and if connect fails clone clears
|
||||
// `buffer` in the store so the next mount has nothing to replay.
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
startResize()
|
||||
|
||||
let restored = false
|
||||
const applyRestore = async () => {
|
||||
if (restored) return
|
||||
restored = true
|
||||
if (!restore) return
|
||||
logTerminal("restore.apply", {
|
||||
id,
|
||||
serverKey: server.key ?? null,
|
||||
directory,
|
||||
restoreLength: restore.length,
|
||||
})
|
||||
await write(restore)
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (scrollY !== undefined) t.scrollToLine(scrollY)
|
||||
startResize()
|
||||
} else {
|
||||
fit.fit()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
if (restore) {
|
||||
await write(restore)
|
||||
if (scrollY !== undefined) t.scrollToLine(scrollY)
|
||||
}
|
||||
startResize()
|
||||
}
|
||||
|
||||
const once = { value: false }
|
||||
@@ -509,17 +526,34 @@ export const Terminal = (props: TerminalProps) => {
|
||||
if (disposed) return
|
||||
drop?.()
|
||||
|
||||
const next = new URL(url + `/pty/${id}/connect`)
|
||||
const baseUrl = currentUrl()
|
||||
if (sdk.url !== baseUrl) {
|
||||
console.error(
|
||||
`[terminal panic] sdk.url mismatch id=${id} serverKey=${server.key ?? ""} directory=${directory} sdkUrl=${sdk.url} currentUrl=${baseUrl}`,
|
||||
)
|
||||
}
|
||||
|
||||
const next = new URL(baseUrl + `/pty/${id}/connect`)
|
||||
next.searchParams.set("directory", directory)
|
||||
next.searchParams.set("cursor", String(seek))
|
||||
next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
|
||||
if (!sameOrigin && password) {
|
||||
if (!sameOrigin() && password) {
|
||||
next.searchParams.set("auth_token", btoa(`${username}:${password}`))
|
||||
// For same-origin requests, let the browser reuse the page's existing auth.
|
||||
next.username = username
|
||||
next.password = password
|
||||
}
|
||||
|
||||
logTerminal("socket.open", {
|
||||
id,
|
||||
serverKey: server.key ?? null,
|
||||
directory,
|
||||
restoreLength: restore.length,
|
||||
sdkUrl: sdk.url,
|
||||
currentUrl: baseUrl,
|
||||
wsUrl: next.toString(),
|
||||
})
|
||||
|
||||
const socket = new WebSocket(next)
|
||||
socket.binaryType = "arraybuffer"
|
||||
ws = socket
|
||||
@@ -527,6 +561,16 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const handleOpen = () => {
|
||||
if (disposed) return
|
||||
tries = 0
|
||||
logTerminal("socket.connected", {
|
||||
id,
|
||||
serverKey: server.key ?? null,
|
||||
directory,
|
||||
currentUrl: baseUrl,
|
||||
})
|
||||
// Paint the saved buffer now that we've confirmed the pty really
|
||||
// exists on the current sidecar. Fire-and-forget: write()'s own
|
||||
// flush keeps the data ordered with incoming WS messages.
|
||||
void applyRestore()
|
||||
local.onConnect?.()
|
||||
scheduleSize(t.cols, t.rows)
|
||||
}
|
||||
@@ -581,6 +625,14 @@ export const Terminal = (props: TerminalProps) => {
|
||||
socket.removeEventListener("close", handleClose)
|
||||
if (disposed) return
|
||||
if (event.code === 1000) return
|
||||
logTerminal("socket.closed", {
|
||||
id,
|
||||
serverKey: server.key ?? null,
|
||||
directory,
|
||||
code: event.code,
|
||||
reason: event.reason || null,
|
||||
currentUrl: baseUrl,
|
||||
})
|
||||
retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code })))
|
||||
}
|
||||
|
||||
@@ -591,6 +643,29 @@ 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()
|
||||
}
|
||||
|
||||
@@ -606,6 +681,13 @@ export const Terminal = (props: TerminalProps) => {
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
logTerminal("cleanup", {
|
||||
id,
|
||||
serverKey: server.key ?? null,
|
||||
directory,
|
||||
cursor,
|
||||
restoreLength: restore.length,
|
||||
})
|
||||
disposed = true
|
||||
if (fitFrame !== undefined) cancelAnimationFrame(fitFrame)
|
||||
if (sizeTimer !== undefined) clearTimeout(sizeTimer)
|
||||
|
||||
@@ -235,7 +235,9 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
|
||||
})
|
||||
|
||||
return {
|
||||
url: currentServer.http.url,
|
||||
get url() {
|
||||
return server.current?.http.url ?? currentServer.http.url
|
||||
},
|
||||
client: sdk,
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
|
||||
@@ -171,6 +171,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
if (state.active !== input) setState("active", input)
|
||||
}
|
||||
|
||||
function nextActiveKey(exclude?: ServerConnection.Key) {
|
||||
const available = allServers().filter((conn) => ServerConnection.key(conn) !== exclude)
|
||||
const preferred = available.find((conn) => ServerConnection.key(conn) === props.defaultServer)
|
||||
const next = preferred ?? available[0]
|
||||
return next ? ServerConnection.key(next) : props.defaultServer
|
||||
}
|
||||
|
||||
function add(input: ServerConnection.Http) {
|
||||
const url_ = normalizeServerUrl(input.http.url)
|
||||
if (!url_) return
|
||||
@@ -192,8 +199,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
batch(() => {
|
||||
setStore("list", list)
|
||||
if (state.active === key) {
|
||||
const next = list[0]
|
||||
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
|
||||
setState("active", nextActiveKey(key))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -239,6 +245,14 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
|
||||
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const list = allServers()
|
||||
if (!list.length) return
|
||||
if (list.some((conn) => ServerConnection.key(conn) === state.active)) return
|
||||
setState("active", nextActiveKey(state.active))
|
||||
})
|
||||
|
||||
const isLocal = createMemo(() => {
|
||||
const c = current()
|
||||
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
|
||||
let getWorkspaceTerminalCacheKey: (dir: string) => string
|
||||
let getWorkspaceTerminalCacheKey: (dir: string, serverKey: string) => string
|
||||
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
|
||||
let migrateTerminalState: (value: unknown) => unknown
|
||||
|
||||
@@ -22,8 +22,9 @@ beforeAll(async () => {
|
||||
})
|
||||
|
||||
describe("getWorkspaceTerminalCacheKey", () => {
|
||||
test("uses workspace-only directory cache key", () => {
|
||||
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
|
||||
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__")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useServer } from "./server"
|
||||
import type { Platform } from "./platform"
|
||||
import { defaultTitle, titleNumber } from "./terminal-title"
|
||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||
@@ -21,6 +22,11 @@ export type LocalPTY = {
|
||||
const WORKSPACE_KEY = "__workspace__"
|
||||
const MAX_TERMINAL_SESSIONS = 20
|
||||
|
||||
const debugTerminal = (phase: string, input: Record<string, unknown>) => {
|
||||
if (!import.meta.env.DEV) return
|
||||
console.log(`[terminal context] ${JSON.stringify({ phase, ...input })}`)
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -82,8 +88,8 @@ export function migrateTerminalState(value: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkspaceTerminalCacheKey(dir: string) {
|
||||
return `${dir}:${WORKSPACE_KEY}`
|
||||
export function getWorkspaceTerminalCacheKey(dir: string, serverKey: string) {
|
||||
return `${dir}:${serverKey}:${WORKSPACE_KEY}`
|
||||
}
|
||||
|
||||
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
|
||||
@@ -111,10 +117,11 @@ const trimTerminal = (pty: LocalPTY) => {
|
||||
}
|
||||
|
||||
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
for (const cache of caches) {
|
||||
const entry = cache.get(key)
|
||||
entry?.value.clear()
|
||||
for (const [key, entry] of cache.entries()) {
|
||||
if (!key.startsWith(`${dir}:`) || !key.endsWith(`:${WORKSPACE_KEY}`)) continue
|
||||
entry.value.clear()
|
||||
}
|
||||
}
|
||||
|
||||
void removePersisted(Persist.workspace(dir, "terminal"), platform)
|
||||
@@ -130,14 +137,25 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
|
||||
}
|
||||
}
|
||||
|
||||
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
||||
function createWorkspaceTerminalSession(
|
||||
sdk: ReturnType<typeof useSDK>,
|
||||
dir: string,
|
||||
serverKey: string,
|
||||
legacySessionID?: string,
|
||||
) {
|
||||
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
||||
const target = {
|
||||
...Persist.workspace(dir, `${serverKey}:terminal`, legacy),
|
||||
migrate: migrateTerminalState,
|
||||
}
|
||||
|
||||
// Scope persisted terminal state by server so switching servers behaves
|
||||
// like switching projects: a fresh session for the new server+dir pair,
|
||||
// while the other server's state stays intact until you swap back. PTY
|
||||
// ids, scrollback, and WebSocket connections are all server-scoped, so
|
||||
// cross-server persistence was showing stale output on swap.
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.workspace(dir, "terminal", legacy),
|
||||
migrate: migrateTerminalState,
|
||||
},
|
||||
target,
|
||||
createStore<{
|
||||
active?: string
|
||||
all: LocalPTY[]
|
||||
@@ -146,6 +164,14 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
}),
|
||||
)
|
||||
|
||||
debugTerminal("session.create", {
|
||||
dir,
|
||||
serverKey,
|
||||
storage: target.storage,
|
||||
key: target.key,
|
||||
legacySessionID: legacySessionID ?? null,
|
||||
})
|
||||
|
||||
const pickNextTerminalNumber = () => {
|
||||
const existingTitleNumbers = new Set(
|
||||
store.all.flatMap((pty) => {
|
||||
@@ -186,6 +212,16 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
onCleanup(unsub)
|
||||
|
||||
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
|
||||
debugTerminal("session.update", {
|
||||
dir,
|
||||
serverKey,
|
||||
id: pty.id,
|
||||
title: pty.title ?? null,
|
||||
hasBuffer: typeof pty.buffer === "string",
|
||||
bufferLength: typeof pty.buffer === "string" ? pty.buffer.length : 0,
|
||||
cursor: pty.cursor ?? null,
|
||||
scrollY: pty.scrollY ?? null,
|
||||
})
|
||||
const index = store.all.findIndex((x) => x.id === pty.id)
|
||||
const previous = index >= 0 ? store.all[index] : undefined
|
||||
if (index >= 0) {
|
||||
@@ -202,11 +238,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
|
||||
if (currentIndex >= 0) setStore("all", currentIndex, previous)
|
||||
}
|
||||
console.error("Failed to update terminal", error)
|
||||
console.error(
|
||||
`Failed to update terminal ${JSON.stringify({
|
||||
ptyID: pty.id,
|
||||
title: pty.title,
|
||||
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : error,
|
||||
})}`,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
|
||||
debugTerminal("session.clone.start", { dir, serverKey, id })
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
const pty = store.all[index]
|
||||
if (!pty) return
|
||||
@@ -220,6 +263,14 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
})
|
||||
if (!next?.data) return
|
||||
|
||||
debugTerminal("session.clone.done", {
|
||||
dir,
|
||||
serverKey,
|
||||
id,
|
||||
nextID: next.data.id ?? null,
|
||||
title: next.data.title ?? pty.title,
|
||||
})
|
||||
|
||||
const active = store.active === pty.id
|
||||
|
||||
batch(() => {
|
||||
@@ -252,11 +303,19 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
new() {
|
||||
const nextNumber = pickNextTerminalNumber()
|
||||
|
||||
debugTerminal("session.new", { dir, serverKey, nextNumber })
|
||||
|
||||
sdk.client.pty
|
||||
.create({ title: defaultTitle(nextNumber) })
|
||||
.then((pty: { data?: { id?: string; title?: string } }) => {
|
||||
const id = pty.data?.id
|
||||
if (!id) return
|
||||
debugTerminal("session.new.done", {
|
||||
dir,
|
||||
serverKey,
|
||||
id,
|
||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
||||
})
|
||||
const newTerminal = {
|
||||
id,
|
||||
title: pty.data?.title ?? defaultTitle(nextNumber),
|
||||
@@ -289,6 +348,12 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
|
||||
},
|
||||
bind() {
|
||||
const client = sdk.client
|
||||
debugTerminal("session.bind", {
|
||||
dir,
|
||||
serverKey,
|
||||
active: store.active ?? null,
|
||||
all: store.all.map((item) => item.id),
|
||||
})
|
||||
return {
|
||||
trim(id: string) {
|
||||
const index = store.all.findIndex((x) => x.id === id)
|
||||
@@ -357,6 +422,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
const server = useServer()
|
||||
const params = useParams()
|
||||
const cache = new Map<string, TerminalCacheEntry>()
|
||||
|
||||
@@ -364,7 +430,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
onCleanup(() => caches.delete(cache))
|
||||
|
||||
const disposeAll = () => {
|
||||
// Snapshot disposers, then defer them to a microtask. When this runs
|
||||
// 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
|
||||
@@ -372,7 +438,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
// null (reading '1')` at chunk-*.js:992.
|
||||
const pending = Array.from(cache.values(), (entry) => entry.dispose)
|
||||
cache.clear()
|
||||
if (pending.length) queueMicrotask(() => pending.forEach((d) => d()))
|
||||
if (pending.length) setTimeout(() => pending.forEach((d) => d()), 0)
|
||||
}
|
||||
|
||||
onCleanup(disposeAll)
|
||||
@@ -387,18 +453,33 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkspace = (dir: string, legacySessionID?: string) => {
|
||||
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
|
||||
const key = getWorkspaceTerminalCacheKey(dir)
|
||||
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 existing = cache.get(key)
|
||||
if (existing) {
|
||||
debugTerminal("workspace.cache.hit", {
|
||||
dir,
|
||||
serverKey,
|
||||
key,
|
||||
legacySessionID: legacySessionID ?? null,
|
||||
})
|
||||
cache.delete(key)
|
||||
cache.set(key, existing)
|
||||
return existing.value
|
||||
}
|
||||
|
||||
debugTerminal("workspace.cache.miss", {
|
||||
dir,
|
||||
serverKey,
|
||||
key,
|
||||
legacySessionID: legacySessionID ?? null,
|
||||
})
|
||||
|
||||
const entry = createRoot((dispose) => ({
|
||||
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
|
||||
value: createWorkspaceTerminalSession(sdk, dir, serverKey, legacySessionID),
|
||||
dispose,
|
||||
}))
|
||||
|
||||
@@ -407,16 +488,51 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
return entry.value
|
||||
}
|
||||
|
||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
|
||||
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
|
||||
return loadWorkspace(params.dir!, key, params.id)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => ({ dir: params.dir, id: params.id }),
|
||||
(next, prev) => {
|
||||
if (!prev?.dir) return
|
||||
const prevKey = server.key
|
||||
if (!prev?.dir || !prevKey) return
|
||||
if (next.dir === prev.dir && next.id === prev.id) return
|
||||
if (next.dir === prev.dir && next.id) return
|
||||
loadWorkspace(prev.dir, prev.id).trimAll()
|
||||
loadWorkspace(prev.dir, prevKey, prev.id).trimAll()
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -431,7 +547,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||
trim: (id: string) => workspace().trim(id),
|
||||
trimAll: () => workspace().trimAll(),
|
||||
clone: (id: string) => workspace().clone(id),
|
||||
bind: () => workspace(),
|
||||
bind: () => workspace().bind(),
|
||||
open: (id: string) => workspace().open(id),
|
||||
close: (id: string) => workspace().close(id),
|
||||
move: (id: string, to: number) => workspace().move(id, to),
|
||||
|
||||
@@ -65,6 +65,21 @@ function retryable(error: unknown, signal?: AbortSignal) {
|
||||
return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message)
|
||||
}
|
||||
|
||||
function serializeError(error: unknown): unknown {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
}
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
function stringifyLog(label: string, value: unknown) {
|
||||
return `${label} ${JSON.stringify(value)}`
|
||||
}
|
||||
|
||||
export async function checkServerHealth(
|
||||
server: ServerConnection.HttpBase,
|
||||
fetch: typeof globalThis.fetch,
|
||||
@@ -74,7 +89,19 @@ export async function checkServerHealth(
|
||||
const signal = opts?.signal ?? timeout?.signal
|
||||
const retryCount = opts?.retryCount ?? defaultRetryCount
|
||||
const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs
|
||||
const logFailure = (phase: string, count: number, error: unknown) => {
|
||||
console.error(
|
||||
stringifyLog("[server health] request failed", {
|
||||
phase,
|
||||
attempt: count + 1,
|
||||
url: server.url,
|
||||
hasAuth: !!server.password,
|
||||
error: serializeError(error),
|
||||
}),
|
||||
)
|
||||
}
|
||||
const next = (count: number, error: unknown) => {
|
||||
logFailure("retry", count, error)
|
||||
if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const)
|
||||
return wait(retryDelayMs * (count + 1), signal)
|
||||
.then(() => attempt(count + 1))
|
||||
@@ -87,7 +114,10 @@ export async function checkServerHealth(
|
||||
signal,
|
||||
})
|
||||
.global.health()
|
||||
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
||||
.then((x) => {
|
||||
if (x.error) return next(count, x.error)
|
||||
return { healthy: x.data?.healthy === true, version: x.data?.version }
|
||||
})
|
||||
.catch((error) => next(count, error))
|
||||
return attempt(0).finally(() => timeout?.clear?.())
|
||||
}
|
||||
|
||||
@@ -14,13 +14,40 @@ export function resolveAppPath(appName: string): string | null {
|
||||
return resolveWindowsAppPath(appName)
|
||||
}
|
||||
|
||||
// Parses `\\wsl$\<distro>\...` and `\\wsl.localhost\<distro>\...` UNC paths that
|
||||
// point *into* a WSL distro's rootfs. `wslpath -u` cannot handle these reliably:
|
||||
// backslashes get shell-collapsed when passed through `wsl.exe`, turning
|
||||
// `\\wsl.localhost\Debian\home\luke` into `/mnt/c/wsl.localhostDebianhomeluke`,
|
||||
// which is a valid-looking path that wedges opencode on DrvFs stat calls.
|
||||
function parseWslUncPath(value: string): { distro: string; subpath: string } | null {
|
||||
// Normalise separators; both `\\` and `//` prefixes mean UNC.
|
||||
const normalised = value.replace(/\\/g, "/").replace(/^\/+/, "//")
|
||||
const match = /^\/\/(wsl\$|wsl\.localhost)\/([^/]+)(?:\/(.*))?$/i.exec(normalised)
|
||||
if (!match) return null
|
||||
const distro = match[2]
|
||||
const subpath = match[3] ?? ""
|
||||
return { distro, subpath }
|
||||
}
|
||||
|
||||
export async function wslPath(path: string, mode: "windows" | "linux" | null, distro?: string | null): Promise<string> {
|
||||
if (process.platform !== "win32") return path
|
||||
|
||||
// `\\wsl$\<distro>\...` / `\\wsl.localhost\<distro>\...` -> `/<subpath>` in
|
||||
// the target distro. Do the conversion in-process rather than shelling out
|
||||
// to `wslpath -u`, which mangles backslashes via wsl.exe's command-line
|
||||
// joiner. If the requested distro differs from the UNC distro, we still
|
||||
// translate literally — callers are responsible for only picking paths
|
||||
// inside the active distro.
|
||||
if (mode === "linux") {
|
||||
const unc = parseWslUncPath(path)
|
||||
if (unc) return `/${unc.subpath}`
|
||||
}
|
||||
|
||||
const flag = mode === "windows" ? "-w" : "-u"
|
||||
try {
|
||||
const resolved = path.startsWith("~") ? `${distro ? await resolveWslHome(distro) : "/root"}${path.slice(1)}` : path
|
||||
const output = await runWslInDistro(["wslpath", flag, resolved], distro)
|
||||
const input = mode === "linux" ? resolved.replace(/\\/g, "/") : resolved
|
||||
const output = await runWslInDistro(["wslpath", flag, input], distro)
|
||||
if (output.code !== 0) {
|
||||
throw new Error(output.stderr || output.stdout || `wslpath exited with code ${output.code}`)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { EventEmitter } from "node:events"
|
||||
import { existsSync } from "node:fs"
|
||||
import * as nodeHttp from "node:http"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { Event } from "electron"
|
||||
@@ -41,7 +42,7 @@ import { initLogging } from "./logging"
|
||||
import { parseMarkdown } from "./markdown"
|
||||
import { createMenu } from "./menu"
|
||||
import { allocatePort, getDefaultServerUrl, setDefaultServerUrl, spawnLocalServer, spawnWslSidecar } from "./server"
|
||||
import { store } from "./store"
|
||||
import { getStore } from "./store"
|
||||
import { createWslServersController } from "./wsl-servers"
|
||||
import { createLoadingWindow, createMainWindow, setBackgroundColor, setDockIcon } from "./windows"
|
||||
|
||||
@@ -75,11 +76,15 @@ logger.log("app starting", {
|
||||
version: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
})
|
||||
// NOTE: the first getStore() call here is intentional — it is the earliest
|
||||
// point after `app.setName` / `app.setPath("userData", ...)` have run, so
|
||||
// electron-store correctly resolves its root to the channel-specific
|
||||
// userData dir (`...desktop.dev` in dev) rather than the package.json name.
|
||||
logger.log("config paths", {
|
||||
userData: app.getPath("userData"),
|
||||
settingsStore: store.path,
|
||||
settingsStore: getStore().path,
|
||||
wslServersKey: WSL_SERVERS_KEY,
|
||||
wslServers: store.get(WSL_SERVERS_KEY) ?? null,
|
||||
wslServers: getStore().get(WSL_SERVERS_KEY) ?? null,
|
||||
})
|
||||
|
||||
setupApp()
|
||||
@@ -330,6 +335,7 @@ function wireMenu() {
|
||||
}
|
||||
|
||||
registerIpcHandlers({
|
||||
httpFetch: (input) => bridgedHttpFetch(input),
|
||||
killSidecar: () => killSidecar(),
|
||||
relaunch: () => relaunchApp(),
|
||||
awaitInitialization: async (sendStep) => {
|
||||
@@ -391,6 +397,82 @@ function relaunchApp() {
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
// Uses node:http 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.
|
||||
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:") {
|
||||
reject(new Error(`httpFetch: only http: is supported (got ${parsed.protocol})`))
|
||||
return
|
||||
}
|
||||
|
||||
const req = nodeHttp.request({
|
||||
host: parsed.hostname,
|
||||
port: parsed.port ? Number(parsed.port) : 80,
|
||||
path: `${parsed.pathname}${parsed.search}`,
|
||||
method: input.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[] = []
|
||||
res.on("data", (chunk: Buffer) => chunks.push(chunk))
|
||||
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) => {
|
||||
|
||||
@@ -21,6 +21,18 @@ 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>
|
||||
@@ -56,6 +68,13 @@ type Deps = {
|
||||
}
|
||||
|
||||
export function registerIpcHandlers(deps: Deps) {
|
||||
const debugStore = (op: string, name: string, key: string, meta?: Record<string, unknown>) => {
|
||||
if (app.isPackaged) return
|
||||
if (!name.startsWith("opencode.workspace.")) return
|
||||
if (!key.includes("terminal")) return
|
||||
console.log(`[store ${op}] ${JSON.stringify({ name, key, ...meta })}`)
|
||||
}
|
||||
|
||||
const offWslServers = deps.onWslServersEvent((payload) => {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed()) continue
|
||||
@@ -64,6 +83,13 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
})
|
||||
app.once("will-quit", offWslServers)
|
||||
|
||||
ipcMain.handle(
|
||||
"http-fetch",
|
||||
(
|
||||
_event: IpcMainInvokeEvent,
|
||||
input: { url: string; method: string; headers: Record<string, string>; body?: string; timeoutMs?: number },
|
||||
) => deps.httpFetch(input),
|
||||
)
|
||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
|
||||
const send = (step: InitStep) => event.sender.send("init-step", step)
|
||||
@@ -122,13 +148,24 @@ export function registerIpcHandlers(deps: Deps) {
|
||||
ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
const store = getStore(name)
|
||||
const value = store.get(key)
|
||||
debugStore("get", name, key, {
|
||||
found: value !== undefined && value !== null,
|
||||
length:
|
||||
typeof value === "string"
|
||||
? value.length
|
||||
: value === undefined || value === null
|
||||
? 0
|
||||
: JSON.stringify(value).length,
|
||||
})
|
||||
if (value === undefined || value === null) return null
|
||||
return typeof value === "string" ? value : JSON.stringify(value)
|
||||
})
|
||||
ipcMain.handle("store-set", (_event: IpcMainInvokeEvent, name: string, key: string, value: string) => {
|
||||
debugStore("set", name, key, { length: value.length })
|
||||
getStore(name).set(key, value)
|
||||
})
|
||||
ipcMain.handle("store-delete", (_event: IpcMainInvokeEvent, name: string, key: string) => {
|
||||
debugStore("delete", name, key)
|
||||
getStore(name).delete(key)
|
||||
})
|
||||
ipcMain.handle("store-clear", (_event: IpcMainInvokeEvent, name: string) => {
|
||||
|
||||
@@ -67,7 +67,8 @@ function migrateFile(datPath: string, filename: string) {
|
||||
}
|
||||
|
||||
export function migrate() {
|
||||
if (getStore().get(TAURI_MIGRATED_KEY)) {
|
||||
const store = getStore()
|
||||
if (store.get(TAURI_MIGRATED_KEY)) {
|
||||
log.log("tauri migration: already done, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -10,11 +10,18 @@ import { type WslCommandLine, resolveWslOpencode, wslArgs } from "./wsl"
|
||||
export type HealthCheck = { wait: Promise<void> }
|
||||
|
||||
export function getDefaultServerUrl(): string | null {
|
||||
const value = getStore().get(DEFAULT_SERVER_URL_KEY)
|
||||
return typeof value === "string" ? value : null
|
||||
const store = getStore()
|
||||
const value = store.get(DEFAULT_SERVER_URL_KEY)
|
||||
if (typeof value !== "string") return null
|
||||
if (value === "sidecar") {
|
||||
store.set(DEFAULT_SERVER_URL_KEY, "local:windows")
|
||||
return "local:windows"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function setDefaultServerUrl(url: string | null) {
|
||||
const store = getStore()
|
||||
if (url) {
|
||||
getStore().set(DEFAULT_SERVER_URL_KEY, url)
|
||||
return
|
||||
@@ -73,7 +80,7 @@ export async function spawnLocalServer(hostname: string, port: number, password:
|
||||
}
|
||||
|
||||
export type WslSidecar = {
|
||||
listener: { stop: () => void }
|
||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
||||
url: string
|
||||
username: string | null
|
||||
password: string
|
||||
@@ -98,16 +105,44 @@ export async function spawnWslSidecar(
|
||||
const port = await allocatePort()
|
||||
const password = randomUUID()
|
||||
const username = "opencode"
|
||||
const logLevel = app.isPackaged ? "WARN" : "INFO"
|
||||
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
"export OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true",
|
||||
"export OPENCODE_EXPERIMENTAL_FILEWATCHER=true",
|
||||
// wsl.exe inherits the Windows-side cwd (e.g. C:\Users\Lukem) and maps it
|
||||
// to the distro as /mnt/c/Users/Lukem — a DrvFs/9p path. opencode's
|
||||
// instance middleware falls back to `process.cwd()` when a request
|
||||
// arrives without a `directory=` query or `x-opencode-directory` header
|
||||
// (see opencode server.ts InstanceMiddleware), and then calls
|
||||
// `realpathSync(process.cwd())` synchronously on the main thread. A
|
||||
// statx against a 9p path can wedge the whole event loop in kernel
|
||||
// uninterruptible sleep, freezing the accept loop. Move cwd to the
|
||||
// user's native Linux home so the fallback can't land on DrvFs.
|
||||
'cd "$HOME" || cd /',
|
||||
// wsl.exe by default splices the Windows %PATH% into the distro's $PATH
|
||||
// via the interop layer (every `/mnt/c/Program Files/...` entry). Anything
|
||||
// the sidecar spawns — PTY login shells, plugin helpers, etc. — then
|
||||
// inherits it, which means `which pwsh.exe` resolves to the Windows
|
||||
// PowerShell binary and bash-l profiles that end with
|
||||
// eval "$(oh-my-posh init bash)" (or similar)
|
||||
// silently run Windows pwsh for prompt rendering, whose banner
|
||||
// ("Loading personal and system profiles took Xms.") then shows up in
|
||||
// opencode's terminal pane. We want a clean, Linux-only environment in
|
||||
// the sidecar, so filter every /mnt/* segment out of PATH and clear
|
||||
// WSLENV so no further Windows vars leak in. Users who really need
|
||||
// Windows binaries in the sidecar can invoke them by absolute path.
|
||||
'PATH=$(awk -v RS=: -v ORS=: \'$0 !~ /^\\/mnt\\//\' <<<"$PATH" | sed "s/:$//")',
|
||||
"export PATH",
|
||||
"export WSLENV=",
|
||||
// WSL sidecars often target /mnt/* worktrees. Keep the desktop-only
|
||||
// watcher/discovery features off there because DrvFs/9p stalls can wedge
|
||||
// the server process after it starts listening.
|
||||
"export OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER=true",
|
||||
"export OPENCODE_CLIENT=desktop",
|
||||
`export OPENCODE_SERVER_USERNAME=${shellEscape(username)}`,
|
||||
`export OPENCODE_SERVER_PASSWORD=${shellEscape(password)}`,
|
||||
'export XDG_STATE_HOME="$HOME/.local/state"',
|
||||
`exec ${shellEscape(opencode)} --print-logs --log-level WARN serve --hostname 0.0.0.0 --port ${port}`,
|
||||
`exec ${shellEscape(opencode)} --print-logs --log-level ${logLevel} serve --hostname 0.0.0.0 --port ${port}`,
|
||||
].join("\n")
|
||||
|
||||
const child = spawn("wsl", wslArgs(["bash", "-se"], distro), {
|
||||
@@ -166,6 +201,9 @@ export async function spawnWslSidecar(
|
||||
stop() {
|
||||
child.kill()
|
||||
},
|
||||
onExit(cb) {
|
||||
child.once("exit", cb)
|
||||
},
|
||||
},
|
||||
url,
|
||||
username,
|
||||
|
||||
@@ -4,10 +4,12 @@ import { SETTINGS_STORE } from "./constants"
|
||||
|
||||
const cache = new Map<string, Store>()
|
||||
|
||||
// We cannot instantiate the electron-store at module load time because
|
||||
// module import hoisting causes this to run before app.setPath("userData", ...)
|
||||
// in index.ts has executed, which would result in files being written to the default directory
|
||||
// (e.g. bad: %APPDATA%\@opencode-ai\desktop-electron\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings).
|
||||
// IMPORTANT: do NOT construct Store at module import time. electron-store
|
||||
// resolves `app.getPath("userData")` in its constructor, but our index.ts
|
||||
// only calls `app.setName` / `app.setPath("userData", ...)` AFTER module
|
||||
// imports finish. Constructing eagerly wrote settings (e.g. the WSL server
|
||||
// config) to the default `%APPDATA%\@opencode-ai\desktop-electron` folder
|
||||
// instead of the proper `...desktop.dev` / channel dir.
|
||||
export function getStore(name = SETTINGS_STORE) {
|
||||
const cached = cache.get(name)
|
||||
if (cached) return cached
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/** @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 = ""
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
} from "../preload/types"
|
||||
import { LEGACY_LOCAL_SERVER_KEY, WSL_SERVERS_KEY } from "./constants"
|
||||
import { spawnWslSidecar } from "./server"
|
||||
import { store } from "./store"
|
||||
import { getStore } from "./store"
|
||||
import type { WslCommandLine } from "./wsl"
|
||||
import {
|
||||
installWslDistro,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
} from "./wsl"
|
||||
|
||||
type RunningSidecar = {
|
||||
listener: { stop: () => void }
|
||||
listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void }
|
||||
url: string
|
||||
username: string | null
|
||||
password: string
|
||||
@@ -64,19 +64,29 @@ 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">) => {
|
||||
setState({ transcript: [...state.transcript, { ...line, at: Date.now() }] })
|
||||
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[]) => {
|
||||
store.set(WSL_SERVERS_KEY, { servers })
|
||||
getStore().set(WSL_SERVERS_KEY, { servers })
|
||||
}
|
||||
|
||||
const updateServer = (id: string, update: (item: WslServerItem) => WslServerItem) => {
|
||||
@@ -117,6 +127,30 @@ 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: {
|
||||
...state.opencodeChecks,
|
||||
[distro]: check,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const refreshOpencodeCheck = async (
|
||||
distro: string,
|
||||
opts?: { signal?: AbortSignal; onLine?: (line: WslCommandLine) => void },
|
||||
) => {
|
||||
const resolved = await resolveWslOpencode(distro, opts)
|
||||
const version = resolved ? await readWslCommandVersion(resolved, distro, opts) : null
|
||||
setOpencodeCheck(distro, opencodeCheck(distro, resolved, version, appVersion))
|
||||
}
|
||||
|
||||
const nextStartAttempt = (id: string) => {
|
||||
const next = (startAttempts.get(id) ?? 0) + 1
|
||||
startAttempts.set(id, next)
|
||||
@@ -156,10 +190,26 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
username: sidecar.username,
|
||||
password: sidecar.password,
|
||||
})
|
||||
sidecar.listener.onExit((code, signal) => {
|
||||
if (sidecars.get(id) !== sidecar) return
|
||||
sidecars.delete(id)
|
||||
const message = startupFailure(code, signal)
|
||||
setRuntime(id, { kind: "failed", message })
|
||||
mainLogger?.error("wsl sidecar exited", { id, distro: item.config.distro, code, signal })
|
||||
})
|
||||
void refreshOpencodeCheck(item.config.distro).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
mainLogger?.error("wsl opencode check failed", { id, distro: item.config.distro, message })
|
||||
})
|
||||
mainLogger?.log("wsl sidecar ready", { id, distro: item.config.distro, url: sidecar.url })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (!isCurrentStartAttempt(id, attempt)) return
|
||||
if (isMissingDistroError(message)) {
|
||||
removeMissingServer(id)
|
||||
mainLogger?.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
|
||||
@@ -171,12 +221,12 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
const stopServerInternal = async (id: string) => {
|
||||
const existing = sidecars.get(id)
|
||||
if (!existing) return
|
||||
sidecars.delete(id)
|
||||
try {
|
||||
existing.listener.stop()
|
||||
} catch {
|
||||
// ignore stop errors
|
||||
}
|
||||
sidecars.delete(id)
|
||||
}
|
||||
|
||||
const runJob = async <T>(job: WslJob, runner: (abort: AbortController) => Promise<T>) => {
|
||||
@@ -285,14 +335,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
async probeOpencode(name: string) {
|
||||
await runJob({ kind: "probe-opencode", distro: name, startedAt: Date.now() }, async (abort) => {
|
||||
appendTranscript({ stream: "system", text: `Checking OpenCode in ${name}` })
|
||||
const resolved = await resolveWslOpencode(name, { signal: abort.signal, onLine })
|
||||
const version = resolved ? await readWslCommandVersion(resolved, name, { signal: abort.signal, onLine }) : null
|
||||
setState({
|
||||
opencodeChecks: {
|
||||
...state.opencodeChecks,
|
||||
[name]: opencodeCheck(name, resolved, version, appVersion),
|
||||
},
|
||||
})
|
||||
await refreshOpencodeCheck(name, { signal: abort.signal, onLine })
|
||||
})
|
||||
},
|
||||
|
||||
@@ -310,16 +353,7 @@ export function createWslServersController(appVersion: string, spawnSidecar: Spa
|
||||
if (result.code !== 0) {
|
||||
throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed")
|
||||
}
|
||||
const nextPath = await resolveWslOpencode(name, { signal: abort.signal, onLine })
|
||||
const nextVersion = nextPath
|
||||
? await readWslCommandVersion(nextPath, name, { signal: abort.signal, onLine })
|
||||
: null
|
||||
setState({
|
||||
opencodeChecks: {
|
||||
...state.opencodeChecks,
|
||||
[name]: opencodeCheck(name, nextPath, nextVersion, appVersion),
|
||||
},
|
||||
})
|
||||
await refreshOpencodeCheck(name, { signal: abort.signal, onLine })
|
||||
})
|
||||
},
|
||||
|
||||
@@ -408,6 +442,7 @@ function initialState(): WslServersState {
|
||||
}
|
||||
|
||||
function readPersistedServers(): WslServerConfig[] {
|
||||
const store = getStore()
|
||||
const existing = store.get(WSL_SERVERS_KEY)
|
||||
if (existing && typeof existing === "object") {
|
||||
const record = existing as { servers?: unknown }
|
||||
@@ -420,7 +455,7 @@ function readPersistedServers(): WslServerConfig[] {
|
||||
}
|
||||
|
||||
function migrateLegacyLocalServer(): WslServerConfig[] {
|
||||
const legacy = store.get(LEGACY_LOCAL_SERVER_KEY)
|
||||
const legacy = getStore().get(LEGACY_LOCAL_SERVER_KEY)
|
||||
if (!legacy || typeof legacy !== "object") return []
|
||||
const record = legacy as Record<string, unknown>
|
||||
if (record.mode !== "wsl") return []
|
||||
@@ -507,6 +542,14 @@ function summarize(value: string) {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
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"})`
|
||||
}
|
||||
|
||||
// Re-export types used by callers
|
||||
export type {
|
||||
WslInstalledDistro,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../preload/types"
|
||||
import { runInteractiveCommand } from "./wsl-pty"
|
||||
|
||||
export type WslCommandLine = {
|
||||
stream: "stdout" | "stderr"
|
||||
@@ -13,7 +14,7 @@ export type WslCommandResult = {
|
||||
stderr: string
|
||||
}
|
||||
|
||||
type RunWslOptions = {
|
||||
export type RunWslOptions = {
|
||||
onLine?: (line: WslCommandLine) => void
|
||||
signal?: AbortSignal
|
||||
/**
|
||||
@@ -28,6 +29,7 @@ type RunWslOptions = {
|
||||
}
|
||||
|
||||
const DEFAULT_WSL_TIMEOUT_MS = 20_000
|
||||
const DEFAULT_WSL_INSTALL_TIMEOUT_MS = 15 * 60_000
|
||||
|
||||
// `--user root` bypasses the distro's default-user requirement. A freshly
|
||||
// installed WSL distro (Ubuntu-24.04 in particular) prompts interactively
|
||||
@@ -89,21 +91,37 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) {
|
||||
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 lines = stdoutPending.split(/\r?\n/g)
|
||||
stdoutPending = lines.pop() ?? ""
|
||||
for (const line of lines) opts.onLine?.({ stream: "stdout", text: line })
|
||||
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 lines = stderrPending.split(/\r?\n/g)
|
||||
stderrPending = lines.pop() ?? ""
|
||||
for (const line of lines) opts.onLine?.({ stream: "stderr", text: line })
|
||||
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) => {
|
||||
@@ -288,7 +306,7 @@ export async function listOnlineWslDistros(opts?: RunWslOptions) {
|
||||
}
|
||||
|
||||
export async function installWslRuntime(opts?: RunWslOptions) {
|
||||
return runWsl(["--install", "--no-distribution"], opts)
|
||||
return runWsl(["--install", "--no-distribution"], withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
export async function installWslRuntimeElevated(opts?: RunWslOptions) {
|
||||
@@ -297,18 +315,23 @@ export async function installWslRuntimeElevated(opts?: RunWslOptions) {
|
||||
"$process = Start-Process -FilePath 'wsl.exe' -Verb RunAs -ArgumentList @('--install','--no-distribution') -Wait -PassThru",
|
||||
"if ($null -ne $process.ExitCode) { exit $process.ExitCode }",
|
||||
].join("; ")
|
||||
return runPowerShell(script, opts)
|
||||
return runPowerShell(script, withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
export async function installWslDistro(name: string, opts?: RunWslOptions) {
|
||||
return runWsl(["--install", "-d", name, "--web-download", "--no-launch"], opts)
|
||||
return runInteractiveCommand(
|
||||
"wsl",
|
||||
["--install", "-d", name, "--web-download", "--no-launch"],
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
DEFAULT_WSL_INSTALL_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
export async function installWslOpencode(version: string, distro: string, opts?: RunWslOptions) {
|
||||
return runWslBash(
|
||||
`curl -fsSL https://opencode.ai/install | bash -s -- --version ${shellEscape(version)}`,
|
||||
distro,
|
||||
opts,
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -420,10 +443,17 @@ export async function readWslCommandVersion(command: string, distro: string, opt
|
||||
}
|
||||
|
||||
export async function upgradeWslOpencode(target: string, command: string, distro: string, opts?: RunWslOptions) {
|
||||
return runWslBash(`${shellEscape(command)} upgrade ${shellEscape(target)}`, distro, opts)
|
||||
return runWslBash(
|
||||
`${shellEscape(command)} upgrade ${shellEscape(target)}`,
|
||||
distro,
|
||||
withTimeout(opts, DEFAULT_WSL_INSTALL_TIMEOUT_MS),
|
||||
)
|
||||
}
|
||||
|
||||
export function openWslTerminal(distro?: string | null) {
|
||||
if (distro && !/^[a-zA-Z0-9_.-]+$/.test(distro)) {
|
||||
return Promise.reject(new Error("Invalid distro name"))
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const child = spawn("cmd.exe", ["/c", "start", "", "wsl", ...(distro ? ["-d", distro] : [])], {
|
||||
detached: true,
|
||||
@@ -489,3 +519,10 @@ function summarize(value: string) {
|
||||
function shellEscape(value: string) {
|
||||
return `'${value.replace(/'/g, `'"'"'`)}'`
|
||||
}
|
||||
|
||||
function withTimeout(opts: RunWslOptions | undefined, timeoutMs: number): RunWslOptions {
|
||||
return {
|
||||
...opts,
|
||||
timeoutMs: opts?.timeoutMs ?? timeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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) => {
|
||||
|
||||
@@ -125,6 +125,18 @@ export type TitlebarTheme = {
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
@@ -70,7 +70,7 @@ import {
|
||||
} from "@opencode-ai/app"
|
||||
import type { AsyncStorage } from "@solid-primitives/storage"
|
||||
import { MemoryRouter } from "@solidjs/router"
|
||||
import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { render } from "solid-js/web"
|
||||
import pkg from "../../package.json"
|
||||
import { initI18n, t } from "./i18n"
|
||||
@@ -140,6 +140,48 @@ 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[]>(result: T | null): Promise<T | null> => {
|
||||
const distro = activeWslDistro()
|
||||
if (!result || !distro) return result
|
||||
@@ -272,8 +314,47 @@ const createPlatform = (): Platform => {
|
||||
},
|
||||
|
||||
fetch: (input, init) => {
|
||||
if (input instanceof Request) return fetch(input)
|
||||
return 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)
|
||||
},
|
||||
)
|
||||
})
|
||||
},
|
||||
|
||||
getDefaultServer: async () => {
|
||||
@@ -368,7 +449,7 @@ render(() => {
|
||||
onCleanup(off)
|
||||
}
|
||||
|
||||
const servers = () => {
|
||||
const servers = createMemo(() => {
|
||||
const data = startup.latest?.sidecar
|
||||
const list: ServerConnection.Any[] = []
|
||||
if (data) {
|
||||
@@ -398,7 +479,7 @@ render(() => {
|
||||
url: `http://wsl-${item.config.distro}.invalid`,
|
||||
}
|
||||
list.push({
|
||||
displayName: `WSL: ${item.config.distro}`,
|
||||
displayName: item.config.distro,
|
||||
type: "sidecar",
|
||||
variant: "wsl",
|
||||
distro: item.config.distro,
|
||||
@@ -407,7 +488,7 @@ render(() => {
|
||||
}
|
||||
}
|
||||
return list
|
||||
}
|
||||
})
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
|
||||
@@ -24,7 +24,20 @@ pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String>
|
||||
|
||||
let value = store.get(DEFAULT_SERVER_URL_KEY);
|
||||
match value {
|
||||
Some(v) => Ok(v.as_str().map(String::from)),
|
||||
Some(v) => match v.as_str() {
|
||||
Some("sidecar") => {
|
||||
store.set(
|
||||
DEFAULT_SERVER_URL_KEY,
|
||||
serde_json::Value::String("local:windows".to_string()),
|
||||
);
|
||||
store
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save settings: {}", e))?;
|
||||
Ok(Some("local:windows".to_string()))
|
||||
}
|
||||
Some(value) => Ok(Some(value.to_string())),
|
||||
None => Ok(None),
|
||||
},
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user