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)
|
||||
|
||||
Reference in New Issue
Block a user