diff options
| author | Brendan Allan <[email protected]> | 2026-02-18 23:03:24 +0800 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-18 23:03:24 +0800 |
| commit | 1bb8574179bbf7c49a34ad0e5df522a752af08c2 (patch) | |
| tree | d10e2ec54cc027dc8ffd76bb40031300f9eaeef8 /packages/app/src/components | |
| parent | 2611c35acc3dc64582e15ad1efca36c60a2883a8 (diff) | |
| download | opencode-1bb8574179bbf7c49a34ad0e5df522a752af08c2.tar.gz opencode-1bb8574179bbf7c49a34ad0e5df522a752af08c2.zip | |
app: refactor server management backend (#13813)
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/dialog-select-server.tsx | 180 | ||||
| -rw-r--r-- | packages/app/src/components/prompt-input/submit.test.ts | 51 | ||||
| -rw-r--r-- | packages/app/src/components/prompt-input/submit.ts | 29 | ||||
| -rw-r--r-- | packages/app/src/components/server/server-row.tsx | 19 | ||||
| -rw-r--r-- | packages/app/src/components/status-popover.tsx | 80 | ||||
| -rw-r--r-- | packages/app/src/components/terminal.tsx | 42 |
6 files changed, 222 insertions, 179 deletions
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 4c3780636..fa5d2d36c 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,19 +1,18 @@ -import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" -import { createStore, reconcile } from "solid-js/store" +import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" -import { List } from "@opencode-ai/ui/list" -import { Button } from "@opencode-ai/ui/button" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { IconButton } from "@opencode-ai/ui/icon-button" +import { List } from "@opencode-ai/ui/list" import { TextField } from "@opencode-ai/ui/text-field" -import { normalizeServerUrl, useServer } from "@/context/server" -import { usePlatform } from "@/context/platform" -import { useNavigate } from "@solidjs/router" -import { useLanguage } from "@/context/language" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { useGlobalSDK } from "@/context/global-sdk" import { showToast } from "@opencode-ai/ui/toast" +import { useNavigate } from "@solidjs/router" +import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" +import { createStore, reconcile } from "solid-js/store" import { ServerRow } from "@/components/server/server-row" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { checkServerHealth, type ServerHealth } from "@/utils/server-health" interface AddRowProps { @@ -89,7 +88,7 @@ function useServerPreview(fetcher: typeof fetch) { if (!looksComplete(value)) return const normalized = normalizeServerUrl(value) if (!normalized) return - const result = await checkServerHealth(normalized, fetcher) + const result = await checkServerHealth({ url: normalized }, fetcher) setStatus(result.healthy) } @@ -171,14 +170,13 @@ export function DialogSelectServer() { const dialog = useDialog() const server = useServer() const platform = usePlatform() - const globalSDK = useGlobalSDK() const language = useLanguage() const fetcher = platform.fetch ?? globalThis.fetch const { defaultUrl, canDefault, setDefault } = useDefaultServer(platform, language) const { previewStatus } = useServerPreview(fetcher) let listRoot: HTMLDivElement | undefined const [store, setStore] = createStore({ - status: {} as Record<string, ServerHealth | undefined>, + status: {} as Record<ServerConnection.Key, ServerHealth | undefined>, addServer: { url: "", adding: false, @@ -214,24 +212,25 @@ export function DialogSelectServer() { }) } - const replaceServer = (original: string, next: string) => { - const active = server.url - const nextActive = active === original ? next : active + const replaceServer = (original: ServerConnection.Http, next: string) => { + const active = server.key + const newConn = server.add(next) + if (!newConn) return - server.add(next) + const nextActive = active === ServerConnection.key(original) ? ServerConnection.key(newConn) : active if (nextActive) server.setActive(nextActive) - server.remove(original) + server.remove(ServerConnection.key(original)) } const items = createMemo(() => { - const current = server.url + const current = server.current const list = server.list if (!current) return list if (!list.includes(current)) return [current, ...list] return [current, ...list.filter((x) => x !== current)] }) - const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0]) + const current = createMemo(() => items().find((x) => ServerConnection.key(x) === server.key) ?? items()[0]) const sortedItems = createMemo(() => { const list = items() @@ -246,17 +245,17 @@ export function DialogSelectServer() { return list.slice().sort((a, b) => { if (a === active) return -1 if (b === active) return 1 - const diff = rank(store.status[a]) - rank(store.status[b]) + const diff = rank(store.status[ServerConnection.key(a)]) - rank(store.status[ServerConnection.key(b)]) if (diff !== 0) return diff return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) }) async function refreshHealth() { - const results: Record<string, ServerHealth> = {} + const results: Record<ServerConnection.Key, ServerHealth> = {} await Promise.all( - items().map(async (url) => { - results[url] = await checkServerHealth(url, fetcher) + items().map(async (conn) => { + results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) }), ) setStore("status", reconcile(results)) @@ -269,15 +268,15 @@ export function DialogSelectServer() { onCleanup(() => clearInterval(interval)) }) - async function select(value: string, persist?: boolean) { - if (!persist && store.status[value]?.healthy === false) return + async function select(conn: ServerConnection.Any, persist?: boolean) { + if (!persist && store.status[ServerConnection.key(conn)]?.healthy === false) return dialog.close() if (persist) { - server.add(value) + server.add(conn.http.url) navigate("/") return } - server.setActive(value) + server.setActive(ServerConnection.key(conn)) navigate("/") } @@ -311,7 +310,7 @@ export function DialogSelectServer() { setStore("addServer", { adding: true, error: "" }) - const result = await checkServerHealth(normalized, fetcher) + const result = await checkServerHealth({ url: normalized }, fetcher) setStore("addServer", { adding: false }) if (!result.healthy) { @@ -320,25 +319,25 @@ export function DialogSelectServer() { } resetAdd() - await select(normalized, true) + await select({ type: "http", http: { url: normalized } }, true) } - async function handleEdit(original: string, value: string) { - if (store.editServer.busy) return + async function handleEdit(original: ServerConnection.Any, value: string) { + if (store.editServer.busy || original.type !== "http") return const normalized = normalizeServerUrl(value) if (!normalized) { resetEdit() return } - if (normalized === original) { + if (normalized === original.http.url) { resetEdit() return } setStore("editServer", { busy: true, error: "" }) - const result = await checkServerHealth(normalized, fetcher) + const result = await checkServerHealth({ url: normalized }, fetcher) setStore("editServer", { busy: false }) if (!result.healthy) { @@ -366,7 +365,7 @@ export function DialogSelectServer() { handleAdd(store.addServer.url) } - const handleEditKey = (event: KeyboardEvent, original: string) => { + const handleEditKey = (event: KeyboardEvent, original: ServerConnection.Any) => { event.stopPropagation() if (event.key === "Escape") { event.preventDefault() @@ -378,7 +377,7 @@ export function DialogSelectServer() { handleEdit(original, store.editServer.value) } - async function handleRemove(url: string) { + async function handleRemove(url: ServerConnection.Key) { server.remove(url) if ((await platform.getDefaultServerUrl?.()) === url) { platform.setDefaultServerUrl?.(null) @@ -390,11 +389,14 @@ export function DialogSelectServer() { <div class="flex flex-col gap-2"> <div ref={(el) => (listRoot = el)}> <List - search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: false }} + search={{ + placeholder: language.t("dialog.server.search.placeholder"), + autofocus: false, + }} noInitialSelection emptyMessage={language.t("dialog.server.empty")} items={sortedItems} - key={(x) => x} + key={(x) => x.http.url} onSelect={(x) => { if (x) select(x) }} @@ -428,7 +430,7 @@ export function DialogSelectServer() { return ( <div class="flex items-center gap-3 min-w-0 flex-1 group/item"> <Show - when={store.editServer.id !== i} + when={store.editServer.id !== i.http.url} fallback={ <EditRow value={store.editServer.value} @@ -443,12 +445,12 @@ export function DialogSelectServer() { } > <ServerRow - url={i} - status={store.status[i]} - dimmed={store.status[i]?.healthy === false} + conn={i} + status={store.status[ServerConnection.key(i)]} + dimmed={store.status[ServerConnection.key(i)]?.healthy === false} class="flex items-center gap-3 px-4 min-w-0 flex-1" badge={ - <Show when={defaultUrl() === i}> + <Show when={defaultUrl() === i.http.url}> <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> {language.t("dialog.server.status.default")} </span> @@ -456,59 +458,63 @@ export function DialogSelectServer() { } /> </Show> - <Show when={store.editServer.id !== i}> + <Show when={store.editServer.id !== i.http.url}> <div class="flex items-center justify-center gap-5 pl-4"> <Show when={current() === i}> <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> </Show> - <DropdownMenu> - <DropdownMenu.Trigger - as={IconButton} - icon="dot-grid" - variant="ghost" - class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" - onClick={(e: MouseEvent) => e.stopPropagation()} - onPointerDown={(e: PointerEvent) => e.stopPropagation()} - /> - <DropdownMenu.Portal> - <DropdownMenu.Content class="mt-1"> - <DropdownMenu.Item - onSelect={() => { - setStore("editServer", { - id: i, - value: i, - error: "", - status: store.status[i]?.healthy, - }) - }} - > - <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - <Show when={canDefault() && defaultUrl() !== i}> - <DropdownMenu.Item onSelect={() => setDefault(i)}> - <DropdownMenu.ItemLabel> - {language.t("dialog.server.menu.default")} - </DropdownMenu.ItemLabel> + <Show when={i.type === "http"}> + <DropdownMenu> + <DropdownMenu.Trigger + as={IconButton} + icon="dot-grid" + variant="ghost" + class="shrink-0 size-8 hover:bg-surface-base-hover data-[expanded]:bg-surface-base-active" + onClick={(e: MouseEvent) => e.stopPropagation()} + onPointerDown={(e: PointerEvent) => e.stopPropagation()} + /> + <DropdownMenu.Portal> + <DropdownMenu.Content class="mt-1"> + <DropdownMenu.Item + onSelect={() => { + setStore("editServer", { + id: i.http.url, + value: i.http.url, + error: "", + status: store.status[ServerConnection.key(i)]?.healthy, + }) + }} + > + <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.edit")}</DropdownMenu.ItemLabel> </DropdownMenu.Item> - </Show> - <Show when={canDefault() && defaultUrl() === i}> - <DropdownMenu.Item onSelect={() => setDefault(null)}> + <Show when={canDefault() && defaultUrl() !== i.http.url}> + <DropdownMenu.Item onSelect={() => setDefault(i.http.url)}> + <DropdownMenu.ItemLabel> + {language.t("dialog.server.menu.default")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </Show> + <Show when={canDefault() && defaultUrl() === i.http.url}> + <DropdownMenu.Item onSelect={() => setDefault(null)}> + <DropdownMenu.ItemLabel> + {language.t("dialog.server.menu.defaultRemove")} + </DropdownMenu.ItemLabel> + </DropdownMenu.Item> + </Show> + <DropdownMenu.Separator /> + <DropdownMenu.Item + onSelect={() => handleRemove(ServerConnection.key(i))} + class="text-text-on-critical-base hover:bg-surface-critical-weak" + > <DropdownMenu.ItemLabel> - {language.t("dialog.server.menu.defaultRemove")} + {language.t("dialog.server.menu.delete")} </DropdownMenu.ItemLabel> </DropdownMenu.Item> - </Show> - <DropdownMenu.Separator /> - <DropdownMenu.Item - onSelect={() => handleRemove(i)} - class="text-text-on-critical-base hover:bg-surface-critical-weak" - > - <DropdownMenu.ItemLabel>{language.t("dialog.server.menu.delete")}</DropdownMenu.ItemLabel> - </DropdownMenu.Item> - </DropdownMenu.Content> - </DropdownMenu.Portal> - </DropdownMenu> + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + </Show> </div> </Show> </div> diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 475a0e20f..c3d6a9281 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -12,24 +12,27 @@ let selected = "/repo/worktree-a" const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] -const clientFor = (directory: string) => ({ - session: { - create: async () => { - createdSessions.push(directory) - return { data: { id: `session-${createdSessions.length}` } } +const clientFor = (directory: string) => { + createdClients.push(directory) + return { + session: { + create: async () => { + createdSessions.push(directory) + return { data: { id: `session-${createdSessions.length}` } } + }, + shell: async () => { + sentShell.push(directory) + return { data: undefined } + }, + prompt: async () => ({ data: undefined }), + command: async () => ({ data: undefined }), + abort: async () => ({ data: undefined }), }, - shell: async () => { - sentShell.push(directory) - return { data: undefined } + worktree: { + create: async () => ({ data: { directory: `${directory}/new` } }), }, - prompt: async () => ({ data: undefined }), - command: async () => ({ data: undefined }), - abort: async () => ({ data: undefined }), - }, - worktree: { - create: async () => ({ data: { directory: `${directory}/new` } }), - }, -}) + } +} beforeAll(async () => { const rootClient = clientFor("/repo/main") @@ -88,11 +91,17 @@ beforeAll(async () => { })) mock.module("@/context/sdk", () => ({ - useSDK: () => ({ - directory: "/repo/main", - client: rootClient, - url: "http://localhost:4096", - }), + useSDK: () => { + const sdk = { + directory: "/repo/main", + client: rootClient, + url: "http://localhost:4096", + createClient(opts: any) { + return clientFor(opts.directory) + }, + } + return sdk + }, })) mock.module("@/context/sync", () => ({ diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 6b6f4a4e0..8a3dfc40d 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -1,21 +1,20 @@ -import { Accessor } from "solid-js" -import { useNavigate, useParams } from "@solidjs/router" -import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client" +import type { Message } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" -import { useLocal } from "@/context/local" -import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt" +import { useNavigate, useParams } from "@solidjs/router" +import type { Accessor } from "solid-js" +import type { FileSelection } from "@/context/file" +import { useGlobalSync } from "@/context/global-sync" +import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" +import { useLocal } from "@/context/local" +import { type ImageAttachmentPart, type Prompt, usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" -import { useGlobalSync } from "@/context/global-sync" -import { usePlatform } from "@/context/platform" -import { useLanguage } from "@/context/language" import { Identifier } from "@/utils/id" import { Worktree as WorktreeState } from "@/utils/worktree" -import type { FileSelection } from "@/context/file" -import { setCursorPosition } from "./editor-dom" import { buildRequestParts } from "./build-request-parts" +import { setCursorPosition } from "./editor-dom" type PendingPrompt = { abort: AbortController @@ -56,7 +55,6 @@ export function createPromptSubmit(input: PromptSubmitInput) { const sdk = useSDK() const sync = useSync() const globalSync = useGlobalSync() - const platform = usePlatform() const local = useLocal() const prompt = usePrompt() const layout = useLayout() @@ -175,9 +173,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } if (sessionDirectory !== projectDirectory) { - client = createOpencodeClient({ - baseUrl: sdk.url, - fetch: platform.fetch, + client = sdk.createClient({ directory: sessionDirectory, throwOnError: true, }) @@ -372,7 +368,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { const timer = { id: undefined as number | undefined } const timeout = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => { timer.id = window.setTimeout(() => { - resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) + resolve({ + status: "failed", + message: language.t("workspace.error.stillPreparing"), + }) }, timeoutMs) }) diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index f93bdb33b..12dcebfa9 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/packages/app/src/components/server/server-row.tsx @@ -1,10 +1,19 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" -import { JSXElement, ParentProps, Show, createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" -import { serverDisplayName } from "@/context/server" +import { + createEffect, + createMemo, + createSignal, + type JSXElement, + onCleanup, + onMount, + type ParentProps, + Show, +} from "solid-js" +import { type ServerConnection, serverDisplayName } from "@/context/server" import type { ServerHealth } from "@/utils/server-health" interface ServerRowProps extends ParentProps { - url: string + conn: ServerConnection.Any status?: ServerHealth class?: string nameClass?: string @@ -17,7 +26,7 @@ export function ServerRow(props: ServerRowProps) { const [truncated, setTruncated] = createSignal(false) let nameRef: HTMLSpanElement | undefined let versionRef: HTMLSpanElement | undefined - const name = createMemo(() => serverDisplayName(props.url)) + const name = createMemo(() => serverDisplayName(props.conn)) const check = () => { const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false @@ -27,7 +36,7 @@ export function ServerRow(props: ServerRowProps) { createEffect(() => { name() - props.url + props.conn.http.url props.status?.version queueMicrotask(check) }) diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index ccaa0ff77..006b15780 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -1,21 +1,21 @@ -import { createEffect, createMemo, createSignal, For, onCleanup, Show, type Accessor, type JSXElement } from "solid-js" -import { createStore, reconcile } from "solid-js/store" -import { useNavigate } from "@solidjs/router" +import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Icon } from "@opencode-ai/ui/icon" import { Popover } from "@opencode-ai/ui/popover" -import { Tabs } from "@opencode-ai/ui/tabs" -import { Button } from "@opencode-ai/ui/button" import { Switch } from "@opencode-ai/ui/switch" -import { Icon } from "@opencode-ai/ui/icon" +import { Tabs } from "@opencode-ai/ui/tabs" import { showToast } from "@opencode-ai/ui/toast" -import { useSync } from "@/context/sync" -import { useSDK } from "@/context/sdk" -import { normalizeServerUrl, useServer } from "@/context/server" -import { usePlatform } from "@/context/platform" -import { useLanguage } from "@/context/language" -import { DialogSelectServer } from "./dialog-select-server" +import { useNavigate } from "@solidjs/router" +import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js" +import { createStore, reconcile } from "solid-js/store" import { 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 { useSync } from "@/context/sync" import { checkServerHealth, type ServerHealth } from "@/utils/server-health" +import { DialogSelectServer } from "./dialog-select-server" const pollMs = 10_000 @@ -32,9 +32,9 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => { } const listServersByHealth = ( - list: string[], - active: string | undefined, - status: Record<string, ServerHealth | undefined>, + list: ServerConnection.Any[], + active: ServerConnection.Key | undefined, + status: Record<ServerConnection.Key, ServerHealth | undefined>, ) => { if (!list.length) return list const order = new Map(list.map((url, index) => [url, index] as const)) @@ -45,16 +45,16 @@ const listServersByHealth = ( } return list.slice().sort((a, b) => { - if (a === active) return -1 - if (b === active) return 1 - const diff = rank(status[a]) - rank(status[b]) + if (ServerConnection.key(a) === active) return -1 + if (ServerConnection.key(b) === active) return 1 + const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)]) if (diff !== 0) return diff return (order.get(a) ?? 0) - (order.get(b) ?? 0) }) } -const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => { - const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>) +const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, fetcher: typeof fetch) => { + const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>) createEffect(() => { const list = servers() @@ -63,8 +63,8 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => const refresh = async () => { const results: Record<string, ServerHealth> = {} await Promise.all( - list.map(async (url) => { - results[url] = await checkServerHealth(url, fetcher) + list.map(async (conn) => { + results[ServerConnection.key(conn)] = await checkServerHealth(conn.http, fetcher) }), ) if (dead) return @@ -82,7 +82,7 @@ const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => return status } -const useDefaultServerUrl = ( +const useDefaultServerKey = ( get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined, ) => { const [url, setUrl] = createSignal<string | undefined>() @@ -117,7 +117,14 @@ const useDefaultServerUrl = ( }) }) - return { url, refresh: () => setTick((value) => value + 1) } + return { + key: () => { + const u = url() + if (!u) return + return ServerConnection.key({ type: "http", http: { url: u } }) + }, + refresh: () => setTick((value) => value + 1), + } } const useMcpToggle = (input: { @@ -163,16 +170,16 @@ export function StatusPopover() { const fetcher = platform.fetch ?? globalThis.fetch const servers = createMemo(() => { - const current = server.url + const current = server.current const list = server.list if (!current) return list - if (!list.includes(current)) return [current, ...list] - return [current, ...list.filter((item) => item !== current)] + if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] + return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] }) const health = useServerHealth(servers, fetcher) - const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health)) + const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const mcp = useMcpToggle({ sync, sdk, language }) - const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl) + const defaultServer = useDefaultServerKey(platform.getDefaultServerUrl) 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) @@ -251,8 +258,9 @@ export function StatusPopover() { <div class="flex flex-col px-2 pb-2"> <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14"> <For each={sortedServers()}> - {(url) => { - const isBlocked = () => health[url]?.healthy === false + {(s) => { + const key = ServerConnection.key(s) + const isBlocked = () => health[key]?.healthy === false return ( <button type="button" @@ -264,19 +272,19 @@ export function StatusPopover() { aria-disabled={isBlocked()} onClick={() => { if (isBlocked()) return - server.setActive(url) + server.setActive(key) navigate("/") }} > <ServerRow - url={url} - status={health[url]} + conn={s} + status={health[key]} dimmed={isBlocked()} class="flex items-center gap-2 w-full min-w-0" nameClass="text-14-regular text-text-base truncate" versionClass="text-12-regular text-text-weak truncate" badge={ - <Show when={url === defaultServer.url()}> + <Show when={key === defaultServer.key()}> <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> {language.t("common.default")} </span> @@ -284,7 +292,7 @@ export function StatusPopover() { } > <div class="flex-1" /> - <Show when={url === server.url}> + <Show when={server.current && key === ServerConnection.key(server.current)}> <Icon name="check" size="small" class="text-icon-weak shrink-0" /> </Show> </ServerRow> diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 9048c481c..085a79613 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,14 +1,15 @@ -import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web" -import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" +import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" +import { showToast } from "@opencode-ai/ui/toast" +import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" +import { type ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" +import { SerializeAddon } from "@/addons/serialize" +import { matchKeybind, parseKeybind } from "@/context/command" +import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" +import { useServer } from "@/context/server" import { monoFontFamily, useSettings } from "@/context/settings" -import { parseKeybind, matchKeybind } from "@/context/command" -import { SerializeAddon } from "@/addons/serialize" -import { LocalPTY } from "@/context/terminal" -import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" -import { useLanguage } from "@/context/language" -import { showToast } from "@opencode-ai/ui/toast" +import type { LocalPTY } from "@/context/terminal" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { terminalWriter } from "@/utils/terminal-writer" @@ -106,8 +107,14 @@ const useTerminalUiBindings = (input: { input.container.addEventListener("pointerdown", input.handlePointerDown) input.cleanups.push(() => input.container.removeEventListener("pointerdown", input.handlePointerDown)) - input.container.addEventListener("click", input.handleLinkClick, { capture: true }) - input.cleanups.push(() => input.container.removeEventListener("click", input.handleLinkClick, { capture: true })) + input.container.addEventListener("click", input.handleLinkClick, { + capture: true, + }) + input.cleanups.push(() => + input.container.removeEventListener("click", input.handleLinkClick, { + capture: true, + }), + ) input.term.textarea?.addEventListener("focus", handleTextareaFocus) input.term.textarea?.addEventListener("blur", handleTextareaBlur) @@ -148,6 +155,7 @@ export const Terminal = (props: TerminalProps) => { const settings = useSettings() const theme = useTheme() const language = useLanguage() + const server = useServer() let container!: HTMLDivElement const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"]) let ws: WebSocket | undefined @@ -372,7 +380,13 @@ export const Terminal = (props: TerminalProps) => { serializeAddon = serializer t.open(container) - useTerminalUiBindings({ container, term: t, cleanups, handlePointerDown, handleLinkClick }) + useTerminalUiBindings({ + container, + term: t, + cleanups, + handlePointerDown, + handleLinkClick, + }) focusTerminal() @@ -428,10 +442,8 @@ export const Terminal = (props: TerminalProps) => { url.searchParams.set("directory", sdk.directory) url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - if (window.__OPENCODE__?.serverPassword) { - url.username = "opencode" - url.password = window.__OPENCODE__?.serverPassword - } + url.username = server.current?.http.username ?? "" + url.password = server.current?.http.password ?? "" const socket = new WebSocket(url) socket.binaryType = "arraybuffer" ws = socket |
