mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-17 12:56:41 +02:00
wip
This commit is contained in:
+39
-29
@@ -27,6 +27,7 @@ import {
|
||||
onCleanup,
|
||||
type ParentProps,
|
||||
Show,
|
||||
startTransition,
|
||||
Suspense,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
@@ -47,6 +48,7 @@ import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { WslServersProvider } from "@/context/wsl-servers"
|
||||
import DirectoryLayout from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
@@ -148,11 +150,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
<UiI18nBridge>
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<QueryProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
<WslServersProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</WslServersProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
</UiI18nBridge>
|
||||
@@ -215,33 +219,37 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
)
|
||||
|
||||
return (
|
||||
<Suspense fallback={splash}>
|
||||
<Show
|
||||
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||
fallback={splash}
|
||||
>
|
||||
<Show when={server.ready()} fallback={splash}>
|
||||
<Suspense fallback={splash}>
|
||||
<Show
|
||||
when={startupHealthCheck()}
|
||||
fallback={
|
||||
<ConnectionError
|
||||
onRetry={() => {
|
||||
if (checkMode() === "background") void healthCheckActions.refetch()
|
||||
}}
|
||||
onServerSelected={(key) => {
|
||||
void withServerSwitchOverlay(() => {
|
||||
batch(() => {
|
||||
setCheckMode("blocking")
|
||||
server.setActive(key)
|
||||
})
|
||||
})
|
||||
}}
|
||||
/>
|
||||
}
|
||||
when={checkMode() === "blocking" ? !startupHealthCheck.loading : startupHealthCheck.state !== "pending"}
|
||||
fallback={splash}
|
||||
>
|
||||
{props.children}
|
||||
<Show
|
||||
when={startupHealthCheck()}
|
||||
fallback={
|
||||
<ConnectionError
|
||||
onRetry={() => {
|
||||
if (checkMode() === "background") void healthCheckActions.refetch()
|
||||
}}
|
||||
onServerSelected={(key) => {
|
||||
void withServerSwitchOverlay(() =>
|
||||
startTransition(() => {
|
||||
batch(() => {
|
||||
setCheckMode("blocking")
|
||||
server.setActive(key)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</Suspense>
|
||||
</Suspense>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -336,6 +344,7 @@ export function AppInterface(props: {
|
||||
children?: JSX.Element
|
||||
defaultServer: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
serversReady?: boolean
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
}) {
|
||||
@@ -349,6 +358,7 @@ export function AppInterface(props: {
|
||||
<ServerProvider
|
||||
defaultServer={props.defaultServer}
|
||||
disableHealthCheck={props.disableHealthCheck}
|
||||
serversReady={props.serversReady}
|
||||
servers={props.servers}
|
||||
>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
|
||||
@@ -8,14 +8,15 @@ import { List } from "@opencode-ai/ui/list"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { batch, createEffect, createMemo, createResource, onCleanup, Show, untrack } from "solid-js"
|
||||
import { batch, createEffect, createMemo, onCleanup, Show, startTransition, untrack } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { DialogWslServer } from "@/components/dialog-wsl-server"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useDefaultServer } from "@/context/default-server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { WslServersState } from "@/context/platform"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useWslServers } from "@/context/wsl-servers"
|
||||
import { isPlaceholderServerUrl, type ServerHealth, useCheckServerHealth } from "@/utils/server-health"
|
||||
import { withServerSwitchOverlay } from "@/utils/server-switch"
|
||||
|
||||
@@ -75,36 +76,6 @@ function showRequestError(language: ReturnType<typeof useLanguage>, err: unknown
|
||||
})
|
||||
}
|
||||
|
||||
function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const [defaultKey, defaultUrlActions] = createResource(
|
||||
async () => {
|
||||
try {
|
||||
const key = await platform.getDefaultServer?.()
|
||||
if (!key) return null
|
||||
return key
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
return null
|
||||
}
|
||||
},
|
||||
{ initialValue: null },
|
||||
)
|
||||
|
||||
const canDefault = createMemo(() => !!platform.getDefaultServer && !!platform.setDefaultServer)
|
||||
const setDefault = async (key: ServerConnection.Key | null) => {
|
||||
try {
|
||||
await platform.setDefaultServer?.(key)
|
||||
defaultUrlActions.mutate(key)
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
}
|
||||
|
||||
return { defaultKey, canDefault, setDefault }
|
||||
}
|
||||
|
||||
function useServerPreview() {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
@@ -208,11 +179,11 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const { defaultKey, canDefault, setDefault } = useDefaultServer()
|
||||
const wslServers = useWslServers()
|
||||
const { previewStatus } = useServerPreview()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [store, setStore] = createStore({
|
||||
status: {} as Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
wslState: undefined as WslServersState | undefined,
|
||||
addServer: {
|
||||
url: "",
|
||||
name: "",
|
||||
@@ -329,6 +300,32 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
},
|
||||
}))
|
||||
|
||||
const removeWslMutation = useMutation(() => ({
|
||||
mutationFn: async (key: ServerConnection.Key) => {
|
||||
await platform.wslServers?.removeServer(key)
|
||||
return key
|
||||
},
|
||||
onSuccess: async (key) => {
|
||||
server.remove(key)
|
||||
if (defaultKey() === key) await setDefault(null)
|
||||
},
|
||||
onError: (err) => showRequestError(language, err),
|
||||
}))
|
||||
|
||||
const retryWslMutation = useMutation(() => ({
|
||||
mutationFn: async (key: ServerConnection.Key) => {
|
||||
await platform.wslServers?.startServer(key)
|
||||
},
|
||||
onError: (err) => showRequestError(language, err),
|
||||
}))
|
||||
|
||||
const updateWslMutation = useMutation(() => ({
|
||||
mutationFn: async (distro: string) => {
|
||||
await platform.wslServers?.installOpencode(distro)
|
||||
},
|
||||
onError: (err) => showRequestError(language, err),
|
||||
}))
|
||||
|
||||
const replaceServer = (original: ServerConnection.Http, next: ServerConnection.Http) => {
|
||||
const active = server.key
|
||||
const newConn = server.add(next)
|
||||
@@ -347,7 +344,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
})
|
||||
|
||||
const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0])
|
||||
let resolvePendingWslSelection: VoidFunction | undefined
|
||||
const wslState = () => wslServers.data
|
||||
const healthPollKey = createMemo(() =>
|
||||
items()
|
||||
.map((conn) =>
|
||||
@@ -359,7 +356,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
const isSelectable = (conn: ServerConnection.Any) => !isPlaceholderServerUrl(conn.http.url)
|
||||
const wslRuntime = (conn: ServerConnection.Any) => {
|
||||
if (conn.type !== "sidecar" || conn.variant !== "wsl") return
|
||||
return store.wslState?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime
|
||||
return wslState()?.servers.find((item) => item.config.id === ServerConnection.key(conn))?.runtime
|
||||
}
|
||||
const canRetryWsl = (conn: ServerConnection.Any) => {
|
||||
const runtime = wslRuntime(conn)
|
||||
@@ -406,32 +403,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
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
|
||||
return wslState()?.opencodeChecks[conn.distro] ?? null
|
||||
}
|
||||
|
||||
const displayVersion = (conn: ServerConnection.Any) => {
|
||||
@@ -441,34 +415,35 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
|
||||
async function select(conn: ServerConnection.Any, persist?: boolean) {
|
||||
if (!isSelectable(conn)) return
|
||||
if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return
|
||||
if (!persist && health(ServerConnection.key(conn))?.healthy === false) return
|
||||
const nextKey = ServerConnection.key(conn)
|
||||
const changed = server.key !== nextKey
|
||||
|
||||
const apply = () => {
|
||||
dialog.close()
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
if (changed && typeof window !== "undefined" && window.history?.replaceState) {
|
||||
window.history.replaceState(null, "", "/")
|
||||
} else {
|
||||
props.onNavigateHome?.()
|
||||
}
|
||||
const navigateHome = () => {
|
||||
if (changed && typeof window !== "undefined" && window.history?.replaceState) {
|
||||
window.history.replaceState(null, "", "/")
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
if (changed && typeof window !== "undefined" && window.history?.replaceState) {
|
||||
window.history.replaceState(null, "", "/")
|
||||
} else {
|
||||
props.onNavigateHome?.()
|
||||
}
|
||||
server.setActive(nextKey)
|
||||
})
|
||||
props.onNavigateHome?.()
|
||||
}
|
||||
|
||||
const apply = () =>
|
||||
startTransition(() => {
|
||||
dialog.close()
|
||||
if (persist && conn.type === "http") {
|
||||
server.add(conn)
|
||||
navigateHome()
|
||||
return
|
||||
}
|
||||
|
||||
batch(() => {
|
||||
navigateHome()
|
||||
server.setActive(nextKey)
|
||||
})
|
||||
})
|
||||
|
||||
if (!changed) {
|
||||
apply()
|
||||
await apply()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -480,10 +455,8 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
if (!key) return
|
||||
const conn = items().find((item) => ServerConnection.key(item) === key)
|
||||
if (!conn || !isSelectable(conn)) return
|
||||
const resolve = resolvePendingWslSelection
|
||||
resolvePendingWslSelection = undefined
|
||||
setStore("addWsl", "pendingSelectKey", undefined)
|
||||
void select(conn).finally(() => resolve?.())
|
||||
void select(conn)
|
||||
})
|
||||
|
||||
const handleAddChange = (value: string) => {
|
||||
@@ -559,8 +532,6 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
const resetForm = () => {
|
||||
resetAdd()
|
||||
resetEdit()
|
||||
resolvePendingWslSelection?.()
|
||||
resolvePendingWslSelection = undefined
|
||||
setStore("addWsl", "pendingSelectKey", undefined)
|
||||
setStore("addWsl", "showWizard", false)
|
||||
}
|
||||
@@ -589,7 +560,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
username: conn.http.username ?? "",
|
||||
password: conn.http.password ?? "",
|
||||
error: "",
|
||||
status: store.status[ServerConnection.key(conn)]?.healthy,
|
||||
status: health(ServerConnection.key(conn))?.healthy,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -602,15 +573,13 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
|
||||
const handleAddedWsl = async (distro: string) => {
|
||||
const key = ServerConnection.Key.make(`wsl:${distro}`)
|
||||
setStore("addWsl", "showWizard", false)
|
||||
setStore("addWsl", "pendingSelectKey", key)
|
||||
const conn = items().find((item) => ServerConnection.key(item) === key)
|
||||
if (conn && isSelectable(conn)) {
|
||||
await select(conn)
|
||||
return
|
||||
setStore("addWsl", "pendingSelectKey", undefined)
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
resolvePendingWslSelection = resolve
|
||||
setStore("addWsl", "pendingSelectKey", key)
|
||||
})
|
||||
}
|
||||
|
||||
const submitForm = () => {
|
||||
@@ -657,44 +626,22 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
|
||||
async function handleRemove(key: ServerConnection.Key) {
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) {
|
||||
void platform.setDefaultServer?.(null)
|
||||
}
|
||||
if (defaultKey() === key) await setDefault(null)
|
||||
}
|
||||
|
||||
async function handleRemoveWsl(conn: ServerConnection.Any) {
|
||||
function handleRemoveWsl(conn: ServerConnection.Any) {
|
||||
if (conn.type !== "sidecar" || conn.variant !== "wsl") return
|
||||
const key = ServerConnection.key(conn)
|
||||
try {
|
||||
await platform.wslServers?.removeServer(key)
|
||||
server.remove(key)
|
||||
if ((await platform.getDefaultServer?.()) === key) {
|
||||
void platform.setDefaultServer?.(null)
|
||||
}
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
removeWslMutation.mutate(ServerConnection.key(conn))
|
||||
}
|
||||
|
||||
async function handleRetryWsl(conn: ServerConnection.Any) {
|
||||
function handleRetryWsl(conn: ServerConnection.Any) {
|
||||
if (conn.type !== "sidecar" || conn.variant !== "wsl") return
|
||||
try {
|
||||
await platform.wslServers?.startServer(ServerConnection.key(conn))
|
||||
} catch (err) {
|
||||
showRequestError(language, err)
|
||||
}
|
||||
retryWslMutation.mutate(ServerConnection.key(conn))
|
||||
}
|
||||
|
||||
async function handleUpdateWsl(conn: ServerConnection.Any) {
|
||||
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)
|
||||
}
|
||||
updateWslMutation.mutate(conn.distro)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -753,7 +700,9 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
const isWslSidecar = i.type === "sidecar" && i.variant === "wsl"
|
||||
const wslDistro = i.type === "sidecar" && i.variant === "wsl" ? i.distro : undefined
|
||||
const blocked = () => !isSelectable(i) || health(key)?.healthy === false
|
||||
const hasMenuActionsBeforeDelete = () => i.type === "http" || (isWslSidecar && canRetryWsl(i))
|
||||
const canChangeDefault = () => canDefault() && i.type !== "ssh"
|
||||
const canRemove = () => i.type === "http" || isWslSidecar
|
||||
const hasMenuActionsBeforeDelete = () => canRemove() && (i.type === "http" || canChangeDefault() || canRetryWsl(i))
|
||||
const outdated = () => {
|
||||
const check = wslCheck(i)
|
||||
return versionOlderThan(check?.version, check?.expectedVersion)
|
||||
@@ -765,8 +714,10 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
if (outdated()) return "Update OpenCode"
|
||||
return null
|
||||
}
|
||||
const updating = () =>
|
||||
store.wslState?.job?.kind === "install-opencode" && store.wslState.job.distro === wslDistro
|
||||
const updating = () => {
|
||||
const job = wslState()?.job
|
||||
return job?.kind === "install-opencode" && 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">
|
||||
@@ -793,12 +744,12 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={!!store.wslState?.job}
|
||||
disabled={!!wslState()?.job}
|
||||
class="shrink-0"
|
||||
onPointerDown={(e: PointerEvent) => e.stopPropagation()}
|
||||
onClick={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
void handleUpdateWsl(i)
|
||||
handleUpdateWsl(i)
|
||||
}}
|
||||
>
|
||||
{updating() ? "Updating OpenCode..." : label()}
|
||||
@@ -809,7 +760,7 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
<Icon name="check" class="h-6" />
|
||||
</Show>
|
||||
|
||||
<Show when={i.type === "http" || isWslSidecar}>
|
||||
<Show when={i.type === "http" || i.type === "sidecar"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
@@ -832,19 +783,19 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={isWslSidecar && canRetryWsl(i)}>
|
||||
<DropdownMenu.Item onSelect={() => void handleRetryWsl(i)}>
|
||||
<DropdownMenu.Item onSelect={() => handleRetryWsl(i)}>
|
||||
<DropdownMenu.ItemLabel>Retry start</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={i.type === "http" && canDefault() && defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(key)}>
|
||||
<Show when={canChangeDefault() && defaultKey() !== key}>
|
||||
<DropdownMenu.Item onSelect={() => void setDefault(key)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.default")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
</DropdownMenu.Item>
|
||||
</Show>
|
||||
<Show when={i.type === "http" && canDefault() && defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => setDefault(null)}>
|
||||
<Show when={canChangeDefault() && defaultKey() === key}>
|
||||
<DropdownMenu.Item onSelect={() => void setDefault(null)}>
|
||||
<DropdownMenu.ItemLabel>
|
||||
{language.t("dialog.server.menu.defaultRemove")}
|
||||
</DropdownMenu.ItemLabel>
|
||||
@@ -853,9 +804,15 @@ export function DialogSelectServer(props: DialogSelectServerProps = {}) {
|
||||
<Show when={hasMenuActionsBeforeDelete()}>
|
||||
<DropdownMenu.Separator />
|
||||
</Show>
|
||||
<Show when={i.type === "http" || isWslSidecar}>
|
||||
<Show when={canRemove()}>
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => (isWslSidecar ? void handleRemoveWsl(i) : handleRemove(key))}
|
||||
onSelect={() => {
|
||||
if (isWslSidecar) {
|
||||
handleRemoveWsl(i)
|
||||
return
|
||||
}
|
||||
void handleRemove(key)
|
||||
}}
|
||||
class="text-text-on-critical-base hover:bg-surface-critical-weak"
|
||||
>
|
||||
<DropdownMenu.ItemLabel>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createEffect, createMemo, For, Match, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { WslServerStep, WslServersState } from "@/context/platform"
|
||||
import type { WslServerStep } from "@/context/platform"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useWslServers } from "@/context/wsl-servers"
|
||||
|
||||
const STEPS: WslServerStep[] = ["wsl", "distro", "opencode"]
|
||||
|
||||
@@ -30,42 +32,15 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const wslServers = useWslServers()
|
||||
const [store, setStore] = createStore({
|
||||
state: undefined as WslServersState | undefined,
|
||||
loading: true,
|
||||
step: undefined as WslServerStep | undefined,
|
||||
selectedDistro: null as string | null,
|
||||
installTarget: undefined as string | undefined,
|
||||
adding: false,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const wslServers = platform.wslServers
|
||||
if (!wslServers) return
|
||||
let mounted = true
|
||||
void wslServers
|
||||
.getState()
|
||||
.then((state) => {
|
||||
if (!mounted) return
|
||||
setStore({ state, loading: false })
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!mounted) return
|
||||
requestError(language, err)
|
||||
setStore("loading", false)
|
||||
})
|
||||
const off = wslServers.subscribe((event) => {
|
||||
setStore("state", reconcile(event.state))
|
||||
setStore("loading", false)
|
||||
})
|
||||
onCleanup(() => {
|
||||
mounted = false
|
||||
off()
|
||||
})
|
||||
})
|
||||
|
||||
const current = () => store.state
|
||||
const wslServers = () => platform.wslServers
|
||||
const current = () => wslServers.data
|
||||
const wslApi = () => platform.wslServers
|
||||
const busy = createMemo(() => !!current()?.job || store.adding)
|
||||
const selectedDistro = () => store.selectedDistro
|
||||
const selectedProbe = createMemo(() => {
|
||||
@@ -156,22 +131,94 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
// them back when a probe result updates recommendedStep.
|
||||
const activeStep = createMemo(() => store.step ?? recommendedStep())
|
||||
|
||||
const probeRuntimeMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const api = wslApi()
|
||||
if (!api) return
|
||||
await api.probeRuntime()
|
||||
},
|
||||
}))
|
||||
|
||||
const refreshDistrosMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const api = wslApi()
|
||||
if (!api) return
|
||||
await api.refreshDistros()
|
||||
},
|
||||
}))
|
||||
|
||||
const installWslMutation = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const api = wslApi()
|
||||
if (!api) return
|
||||
await api.installWsl()
|
||||
},
|
||||
}))
|
||||
|
||||
const installDistroMutation = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const api = wslApi()
|
||||
if (!api) return
|
||||
await api.installDistro(name)
|
||||
},
|
||||
}))
|
||||
|
||||
const probeDistroMutation = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const api = wslApi()
|
||||
if (!api) return
|
||||
await api.probeDistro(name)
|
||||
},
|
||||
}))
|
||||
|
||||
const probeOpencodeMutation = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const api = wslApi()
|
||||
if (!api) return
|
||||
await api.probeOpencode(name)
|
||||
},
|
||||
}))
|
||||
|
||||
const installOpencodeMutation = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const api = wslApi()
|
||||
if (!api) return
|
||||
await api.installOpencode(name)
|
||||
},
|
||||
}))
|
||||
|
||||
const openTerminalMutation = useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const api = wslApi()
|
||||
if (!api) return
|
||||
await api.openTerminal(name)
|
||||
},
|
||||
}))
|
||||
|
||||
const addServerMutation = useMutation(() => ({
|
||||
mutationFn: async (distro: string) => {
|
||||
const api = wslApi()
|
||||
if (!api) return
|
||||
return api.addServer(distro)
|
||||
},
|
||||
}))
|
||||
|
||||
const autoProbe = createMemo(() => {
|
||||
const state = current()
|
||||
if (!state || !wslServers() || busy()) return null
|
||||
if (!state || !wslApi() || busy()) return null
|
||||
if (state.pendingRestart) return null
|
||||
if (!state.runtime) return { key: "runtime", run: () => wslServers()!.probeRuntime() }
|
||||
if (!state.runtime) return { key: "runtime", run: () => probeRuntimeMutation.mutateAsync() }
|
||||
if (!wslReady()) return null
|
||||
if (!state.installed.length && !state.online.length) {
|
||||
return { key: "distros", run: () => wslServers()!.refreshDistros() }
|
||||
return { key: "distros", run: () => refreshDistrosMutation.mutateAsync() }
|
||||
}
|
||||
const distro = selectedDistro()
|
||||
if (distro && !state.distroProbes[distro]) {
|
||||
return { key: `probe-distro:${distro}`, run: () => wslServers()!.probeDistro(distro) }
|
||||
return { key: `probe-distro:${distro}`, run: () => probeDistroMutation.mutateAsync(distro) }
|
||||
}
|
||||
if (!distro || !distroReady()) return null
|
||||
if (!state.opencodeChecks[distro]) {
|
||||
return { key: `probe-opencode:${distro}`, run: () => wslServers()!.probeOpencode(distro) }
|
||||
return { key: `probe-opencode:${distro}`, run: () => probeOpencodeMutation.mutateAsync(distro) }
|
||||
}
|
||||
return null
|
||||
})
|
||||
@@ -278,11 +325,9 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
const finish = async () => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
const api = wslServers()
|
||||
if (!api) return
|
||||
setStore("adding", true)
|
||||
try {
|
||||
await api.addServer(distro)
|
||||
await addServerMutation.mutateAsync(distro)
|
||||
if (props.onAdded) {
|
||||
await props.onAdded(distro)
|
||||
} else {
|
||||
@@ -309,32 +354,38 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
locked: stepIndex(step) > stepIndex(recommendedStep()),
|
||||
})),
|
||||
)
|
||||
const loadError = createMemo(() => {
|
||||
const error = wslServers.error
|
||||
if (!error) return "Failed to load WSL state."
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
|
||||
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 pb-1">
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
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",
|
||||
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
|
||||
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
|
||||
}}
|
||||
disabled={item.locked}
|
||||
onClick={() => setStore("step", item.step)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.title}</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={!wslServers.isPending} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">Loading...</div>}>
|
||||
<Show when={!wslServers.isError} fallback={<div class="px-1 py-6 text-14-regular text-text-weak">{loadError()}</div>}>
|
||||
<div class="flex gap-2 pb-1">
|
||||
<For each={steps()}>
|
||||
{(item) => (
|
||||
<button
|
||||
type="button"
|
||||
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",
|
||||
"border-border-weak-base bg-background-base opacity-60": item.state === "locked",
|
||||
"border-icon-warning-base/40 bg-surface-base": item.state === "warning",
|
||||
}}
|
||||
disabled={item.locked}
|
||||
onClick={() => setStore("step", item.step)}
|
||||
>
|
||||
<div class="text-13-medium text-text-strong">{item.title}</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Switch>
|
||||
<Switch>
|
||||
<Match when={activeStep() === "wsl"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
@@ -344,7 +395,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => void run(() => wslServers()!.installWsl())}
|
||||
onClick={() => void run(() => installWslMutation.mutateAsync())}
|
||||
>
|
||||
Install WSL
|
||||
</Button>
|
||||
@@ -355,7 +406,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
<div class="rounded-md border border-border-weak-base px-3 py-3 flex items-center justify-between gap-3">
|
||||
<div class="text-12-regular text-text-warning-base">Windows restart required.</div>
|
||||
<Button variant="secondary" size="large" onClick={() => void platform.restart()}>
|
||||
Restart OpenCode
|
||||
Relaunch OpenCode
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -369,7 +420,23 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
|
||||
<Match when={activeStep() === "distro"}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">Choose a distro</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">Choose a distro</div>
|
||||
<Show when={selectedDistro()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
disabled={busy()}
|
||||
onClick={() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => probeDistroMutation.mutateAsync(distro))
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{distroMessage()}</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -420,7 +487,7 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
variant="secondary"
|
||||
size="small"
|
||||
disabled={busy() || !installTarget()}
|
||||
onClick={() => void run(() => wslServers()!.installDistro(installTarget()!.name))}
|
||||
onClick={() => void run(() => installDistroMutation.mutateAsync(installTarget()!.name))}
|
||||
>
|
||||
{installingDistro() ? "Installing..." : "Install"}
|
||||
</Button>
|
||||
@@ -488,18 +555,32 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !selectedInstalled()}
|
||||
onClick={() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => wslServers()!.openTerminal(distro))
|
||||
}}
|
||||
>
|
||||
Open terminal
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy() || !selectedInstalled()}
|
||||
onClick={() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => openTerminalMutation.mutateAsync(distro))
|
||||
}}
|
||||
>
|
||||
Open terminal
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy() || !selectedDistro()}
|
||||
onClick={() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => probeDistroMutation.mutateAsync(distro))
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<Button
|
||||
@@ -518,20 +599,36 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-14-medium text-text-strong">OpenCode</div>
|
||||
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => wslServers()!.installOpencode(distro))
|
||||
}}
|
||||
>
|
||||
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
|
||||
</Button>
|
||||
</Show>
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={selectedDistro()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => probeOpencodeMutation.mutateAsync(distro))
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Show>
|
||||
<Show when={!opencodeReady() || opencodeCheck()?.matchesDesktop === false}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="large"
|
||||
disabled={busy()}
|
||||
onClick={() => {
|
||||
const distro = selectedDistro()
|
||||
if (!distro) return
|
||||
void run(() => installOpencodeMutation.mutateAsync(distro))
|
||||
}}
|
||||
>
|
||||
{opencodeCheck()?.resolvedPath ? "Update OpenCode" : "Install OpenCode"}
|
||||
</Button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{opencodeMessage()}</div>
|
||||
<Show when={opencodeMismatchCheck()}>
|
||||
@@ -552,58 +649,59 @@ export function DialogWslServer(props: DialogWslServerProps = {}) {
|
||||
</Show>
|
||||
</div>
|
||||
</Match>
|
||||
</Switch>
|
||||
</Switch>
|
||||
|
||||
<Show when={installProgress()}>
|
||||
{(progress) => (
|
||||
<Show when={installProgress()}>
|
||||
{(progress) => (
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 text-14-medium text-text-strong">
|
||||
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
|
||||
<div>Progress</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{progress().title}</div>
|
||||
<div class="rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular whitespace-pre-wrap break-words">
|
||||
<For
|
||||
each={
|
||||
progress().lines.length
|
||||
? progress().lines
|
||||
: [{ stream: "system" as const, text: "Waiting for output...", at: 0 }]
|
||||
}
|
||||
>
|
||||
{(line) => (
|
||||
<div
|
||||
classList={{
|
||||
"text-text-warning-base": line.stream === "stderr",
|
||||
"text-text-weak": line.stream !== "stderr",
|
||||
}}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={current()?.lastError && (current()?.transcript.length ?? 0) > 0}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 text-14-medium text-text-strong">
|
||||
<Spinner class="h-4 w-4 text-icon-info-base shrink-0" />
|
||||
<div>Progress</div>
|
||||
</div>
|
||||
<div class="text-12-regular text-text-weak whitespace-pre-wrap break-words">{progress().title}</div>
|
||||
<div class="rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular whitespace-pre-wrap break-words">
|
||||
<For
|
||||
each={
|
||||
progress().lines.length
|
||||
? progress().lines
|
||||
: [{ stream: "system" as const, text: "Waiting for output...", at: 0 }]
|
||||
}
|
||||
>
|
||||
{(line) => (
|
||||
<div
|
||||
classList={{
|
||||
"text-text-warning-base": line.stream === "stderr",
|
||||
"text-text-weak": line.stream !== "stderr",
|
||||
}}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="text-14-medium text-text-strong">Diagnostics</div>
|
||||
<div class="rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular text-text-weak whitespace-pre-wrap break-words">
|
||||
<For each={current()?.transcript ?? []}>{(line) => <div>{line.text}</div>}</For>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={current()?.lastError && (current()?.transcript.length ?? 0) > 0}>
|
||||
<div class="rounded-md bg-surface-base p-4 flex flex-col gap-2">
|
||||
<div class="text-14-medium text-text-strong">Diagnostics</div>
|
||||
<div class="rounded-md border border-border-weak-base bg-background-base px-3 py-2 font-mono text-12-regular text-text-weak whitespace-pre-wrap break-words">
|
||||
<For each={current()?.transcript ?? []}>{(line) => <div>{line.text}</div>}</For>
|
||||
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="large" disabled={store.adding || busy()} onClick={() => void finish()}>
|
||||
{store.adding ? "Adding..." : "Add WSL server"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={activeStep() === "opencode" && allReady() && selectedDistro()}>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button variant="ghost" size="large" disabled={store.adding} onClick={() => dialog.close()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" size="large" disabled={store.adding || busy()} onClick={() => void finish()}>
|
||||
{store.adding ? "Adding..." : "Add WSL server"}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,11 @@ import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, untrack } from "solid-js"
|
||||
import { type Accessor, batch, createEffect, createMemo, For, type JSXElement, onCleanup, Show, startTransition, untrack } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useDefaultServer } from "@/context/default-server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
@@ -97,49 +97,6 @@ const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Acc
|
||||
return status
|
||||
}
|
||||
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
key: undefined as ServerConnection.Key | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
state.tick
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("key", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("key", next ? ServerConnection.Key.make(next) : undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState("key", ServerConnection.Key.make(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: () => state.key,
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
@@ -165,11 +122,11 @@ const useMcpToggleMutation = () => {
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const sync = useSync()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const defaultServer = useDefaultServer()
|
||||
|
||||
const [load, setLoad] = createStore({
|
||||
lspDone: false,
|
||||
@@ -240,7 +197,6 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const health = useServerHealth(servers, props.shown)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
@@ -296,23 +252,25 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
"hover:bg-surface-raised-base-hover": !blocked(),
|
||||
"cursor-not-allowed": blocked(),
|
||||
}}
|
||||
aria-disabled={blocked()}
|
||||
onClick={() => {
|
||||
if (blocked()) return
|
||||
void withServerSwitchOverlay(() => {
|
||||
batch(() => {
|
||||
if (server.key !== key) {
|
||||
if (typeof window !== "undefined" && window.history?.replaceState) {
|
||||
window.history.replaceState(null, "", "/")
|
||||
}
|
||||
} else {
|
||||
navigate("/")
|
||||
}
|
||||
server.setActive(key)
|
||||
})
|
||||
})
|
||||
}}
|
||||
>
|
||||
aria-disabled={blocked()}
|
||||
onClick={() => {
|
||||
if (blocked()) return
|
||||
void withServerSwitchOverlay(() =>
|
||||
startTransition(() => {
|
||||
batch(() => {
|
||||
if (server.key !== key) {
|
||||
if (typeof window !== "undefined" && window.history?.replaceState) {
|
||||
window.history.replaceState(null, "", "/")
|
||||
}
|
||||
} else {
|
||||
navigate("/")
|
||||
}
|
||||
server.setActive(key)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
@@ -322,7 +280,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={key === defaultServer.key()}>
|
||||
<Show when={key === defaultServer.defaultKey()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
@@ -348,7 +306,7 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(
|
||||
() => <x.DialogSelectServer onNavigateHome={() => navigate("/")} />,
|
||||
defaultServer.refresh,
|
||||
() => void defaultServer.query.refetch(),
|
||||
)
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { queryOptions, skipToken, useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { useLanguage } from "./language"
|
||||
import { usePlatform } from "./platform"
|
||||
import { ServerConnection } from "./server"
|
||||
|
||||
const defaultServerQueryKey = ["platform", "defaultServer"] as const
|
||||
|
||||
function defaultServerQueryOptions(getDefaultServer: ReturnType<typeof usePlatform>["getDefaultServer"]) {
|
||||
return queryOptions<ServerConnection.Key | null>({
|
||||
queryKey: defaultServerQueryKey,
|
||||
queryFn: getDefaultServer
|
||||
? () => getDefaultServer().then((next) => (next ? ServerConnection.Key.make(next) : null))
|
||||
: skipToken,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
}
|
||||
|
||||
export function useDefaultServer() {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const queryClient = useQueryClient()
|
||||
const query = useQuery(() => ({ ...defaultServerQueryOptions(platform.getDefaultServer) }))
|
||||
const mutation = useMutation(() => ({
|
||||
mutationFn: async (key: ServerConnection.Key | null) => {
|
||||
if (!platform.setDefaultServer) return key
|
||||
await platform.setDefaultServer(key)
|
||||
return key
|
||||
},
|
||||
onSuccess: (key) => {
|
||||
queryClient.setQueryData(defaultServerQueryKey, key)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
canDefault: () => !!platform.getDefaultServer && !!platform.setDefaultServer,
|
||||
defaultKey: () => query.data ?? null,
|
||||
query,
|
||||
setDefault(key: ServerConnection.Key | null) {
|
||||
if (!platform.setDefaultServer) return Promise.resolve(key)
|
||||
return mutation.mutateAsync(key)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -97,9 +97,11 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
init: (props: {
|
||||
defaultServer: ServerConnection.Key
|
||||
disableHealthCheck?: boolean
|
||||
serversReady?: boolean
|
||||
servers?: Array<ServerConnection.Any>
|
||||
}) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const serversReady = () => props.serversReady ?? true
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("server", ["server.v3"]),
|
||||
@@ -204,8 +206,6 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
})
|
||||
}
|
||||
|
||||
const isReady = createMemo(() => ready() && !!state.active)
|
||||
|
||||
const check = (conn: ServerConnection.Any) =>
|
||||
checkServerHealth(conn.http).then((x) => {
|
||||
if (!x.healthy) {
|
||||
@@ -229,14 +229,19 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
|
||||
const origin = createMemo(() => projectsKey(state.active))
|
||||
const projectsList = createMemo(() => store.projects[origin()] ?? [])
|
||||
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
|
||||
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
|
||||
)
|
||||
const current: Accessor<ServerConnection.Any | undefined> = createMemo(() => {
|
||||
const list = allServers()
|
||||
const active = list.find((s) => ServerConnection.key(s) === state.active)
|
||||
if (active) return active
|
||||
if (!serversReady()) return
|
||||
return list[0]
|
||||
})
|
||||
const healthTarget = createMemo(() => {
|
||||
const conn = current()
|
||||
if (!conn) return ""
|
||||
return [ServerConnection.key(conn), conn.http.url, conn.http.username ?? "", conn.http.password ?? ""].join("\n")
|
||||
})
|
||||
const isReady = createMemo(() => ready() && !!current())
|
||||
|
||||
createEffect(() => {
|
||||
healthTarget()
|
||||
@@ -257,6 +262,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!serversReady()) return
|
||||
const list = allServers()
|
||||
if (!list.length) return
|
||||
if (list.some((conn) => ServerConnection.key(conn) === state.active)) return
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import { queryOptions, skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import type { WslServersPlatform, WslServersState } from "./platform"
|
||||
import { usePlatform } from "./platform"
|
||||
|
||||
export const wslServersQueryKey = ["platform", "wslServers"] as const
|
||||
|
||||
export function wslServersQueryOptions(api: WslServersPlatform | undefined) {
|
||||
return queryOptions<WslServersState>({
|
||||
queryKey: wslServersQueryKey,
|
||||
queryFn: api ? () => api.getState() : skipToken,
|
||||
staleTime: Number.POSITIVE_INFINITY,
|
||||
gcTime: Number.POSITIVE_INFINITY,
|
||||
})
|
||||
}
|
||||
|
||||
export const { use: useWslServers, provider: WslServersProvider } = createSimpleContext({
|
||||
name: "WslServers",
|
||||
init: () => {
|
||||
const platform = usePlatform()
|
||||
const queryClient = useQueryClient()
|
||||
const query = useQuery(() => ({ ...wslServersQueryOptions(platform.wslServers) }))
|
||||
|
||||
createEffect(() => {
|
||||
const api = platform.wslServers
|
||||
if (!api) return
|
||||
const off = api.subscribe((event) => {
|
||||
queryClient.setQueryData(wslServersQueryKey, event.state)
|
||||
})
|
||||
onCleanup(off)
|
||||
})
|
||||
|
||||
return query
|
||||
},
|
||||
})
|
||||
@@ -3,6 +3,7 @@ export { DialogWslServer } from "./components/dialog-wsl-server"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||
export { useWslServers } from "./context/wsl-servers"
|
||||
export {
|
||||
type DisplayBackend,
|
||||
type Platform,
|
||||
|
||||
@@ -437,59 +437,39 @@ render(() => {
|
||||
|
||||
const [defaultServer] = createResource(() =>
|
||||
platform.getDefaultServer?.().then((url) => {
|
||||
if (url) return ServerConnection.key({ type: "http", http: { url } })
|
||||
if (url) return ServerConnection.Key.make(url)
|
||||
}),
|
||||
)
|
||||
const [locale] = createResource(loadLocale)
|
||||
|
||||
const [wslServers, setWslServers] = createSignal<WslServersState | null>(null)
|
||||
const [storedServers] = createResource(async () => {
|
||||
const raw = await platform.storage?.("opencode.global.dat").getItem("server")
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { list?: unknown }
|
||||
return Array.isArray(parsed.list) ? parsed.list : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
const [wslServers, setWslServers] = createSignal<WslServersState | undefined>()
|
||||
const [wslReady, setWslReady] = createSignal(!platform.wslServers)
|
||||
if (platform.wslServers) {
|
||||
void platform.wslServers.getState().then((state) => setWslServers(state))
|
||||
const off = platform.wslServers.subscribe((event: WslServersEvent) => setWslServers(event.state))
|
||||
void platform.wslServers
|
||||
.getState()
|
||||
.then((state) => {
|
||||
setWslServers(state)
|
||||
setWslReady(true)
|
||||
})
|
||||
.catch(() => {
|
||||
setWslReady(true)
|
||||
})
|
||||
const off = platform.wslServers.subscribe((event: WslServersEvent) => {
|
||||
setWslServers(event.state)
|
||||
setWslReady(true)
|
||||
})
|
||||
onCleanup(off)
|
||||
}
|
||||
|
||||
const servers = createMemo(() => {
|
||||
const data = startup.latest?.sidecar
|
||||
const list: ServerConnection.Any[] = []
|
||||
if (data) {
|
||||
list.push({
|
||||
displayName: "Local Server",
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: {
|
||||
url: data.local.url,
|
||||
username: data.local.username ?? undefined,
|
||||
password: data.local.password ?? undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
const wsl = wslServers()
|
||||
if (wsl) {
|
||||
for (const item of wsl.servers) {
|
||||
const runtime = item.runtime
|
||||
const http =
|
||||
runtime.kind === "ready"
|
||||
? {
|
||||
url: runtime.url,
|
||||
username: runtime.username ?? undefined,
|
||||
password: runtime.password ?? undefined,
|
||||
}
|
||||
: {
|
||||
url: `http://wsl-${item.config.distro}.invalid`,
|
||||
}
|
||||
list.push({
|
||||
displayName: item.config.distro,
|
||||
type: "sidecar",
|
||||
variant: "wsl",
|
||||
distro: item.config.distro,
|
||||
http,
|
||||
})
|
||||
}
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null
|
||||
if (link?.href) {
|
||||
@@ -516,6 +496,76 @@ render(() => {
|
||||
return null
|
||||
}
|
||||
|
||||
function App() {
|
||||
const splash = (
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ready = createMemo(
|
||||
() =>
|
||||
!defaultServer.loading &&
|
||||
!startup.loading &&
|
||||
!windowCount.loading &&
|
||||
!locale.loading,
|
||||
)
|
||||
const servers = createMemo(() => {
|
||||
const data = startup.latest?.sidecar
|
||||
const list: ServerConnection.Any[] = []
|
||||
if (data) {
|
||||
list.push({
|
||||
displayName: "Local Server",
|
||||
type: "sidecar",
|
||||
variant: "base",
|
||||
http: {
|
||||
url: data.local.url,
|
||||
username: data.local.username ?? undefined,
|
||||
password: data.local.password ?? undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
for (const item of wslServers()?.servers ?? []) {
|
||||
const runtime = item.runtime
|
||||
list.push({
|
||||
displayName: item.config.distro,
|
||||
type: "sidecar",
|
||||
variant: "wsl",
|
||||
distro: item.config.distro,
|
||||
http:
|
||||
runtime.kind === "ready"
|
||||
? {
|
||||
url: runtime.url,
|
||||
username: runtime.username ?? undefined,
|
||||
password: runtime.password ?? undefined,
|
||||
}
|
||||
: { url: `http://wsl-${item.config.distro}.invalid` },
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
const hasFallbackServers = createMemo(() => {
|
||||
if ((storedServers.latest?.length ?? 0) > 0) return true
|
||||
return (wslServers()?.servers.length ?? 0) > 0
|
||||
})
|
||||
|
||||
if (!ready()) return splash
|
||||
if (startup.latest?.error && !storedServers.loading && !hasFallbackServers()) {
|
||||
return <LocalServerStartupError message={startup.latest.error} />
|
||||
}
|
||||
|
||||
return (
|
||||
<AppInterface
|
||||
defaultServer={defaultServer.latest ?? ServerConnection.Key.make(startup.latest?.sidecar?.local.key ?? "local:windows")}
|
||||
serversReady={wslReady()}
|
||||
servers={servers()}
|
||||
router={MemoryRouter}
|
||||
>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("click", handleClick)
|
||||
onCleanup(() => {
|
||||
@@ -526,25 +576,7 @@ render(() => {
|
||||
return (
|
||||
<PlatformProvider value={platform}>
|
||||
<AppBaseProviders locale={locale.latest}>
|
||||
<Show when={!defaultServer.loading && !startup.loading && !windowCount.loading && !locale.loading}>
|
||||
{(_) => {
|
||||
if (startup.latest?.error) {
|
||||
return <LocalServerStartupError message={startup.latest.error} />
|
||||
}
|
||||
return (
|
||||
<AppInterface
|
||||
defaultServer={
|
||||
defaultServer.latest ??
|
||||
ServerConnection.Key.make(startup.latest?.sidecar?.local.key ?? "local:windows")
|
||||
}
|
||||
servers={servers()}
|
||||
router={MemoryRouter}
|
||||
>
|
||||
<Inner />
|
||||
</AppInterface>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
<App />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user