diff options
| author | Adam <[email protected]> | 2025-12-30 07:24:35 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-30 07:24:40 -0600 |
| commit | e0e07c5d48ef0671f63ca8bd9119169ced493fac (patch) | |
| tree | 9377eab8b3cc5d3c2976ccae2d32e22db46ce816 /packages/app/src/components | |
| parent | 281f9e623673e6bbfd9a5f9a8f9aae496abc99f2 (diff) | |
| download | opencode-e0e07c5d48ef0671f63ca8bd9119169ced493fac.tar.gz opencode-e0e07c5d48ef0671f63ca8bd9119169ced493fac.zip | |
feat(app): change server
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/dialog-select-server.tsx | 170 | ||||
| -rw-r--r-- | packages/app/src/components/session-lsp-indicator.tsx | 10 | ||||
| -rw-r--r-- | packages/app/src/components/session-mcp-indicator.tsx | 10 | ||||
| -rw-r--r-- | packages/app/src/components/status-bar.tsx | 29 |
4 files changed, 202 insertions, 17 deletions
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx new file mode 100644 index 000000000..b041bbab6 --- /dev/null +++ b/packages/app/src/components/dialog-select-server.tsx @@ -0,0 +1,170 @@ +import { createEffect, createMemo, onCleanup } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { TextField } from "@opencode-ai/ui/text-field" +import { Button } from "@opencode-ai/ui/button" +import { useServer } from "@/context/server" +import { usePlatform } from "@/context/platform" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { useNavigate } from "@solidjs/router" + +type ServerStatus = { healthy: boolean; version?: string } + +function displayName(url: string) { + return url + .replace(/^https?:\/\//, "") + .replace(/\/+$/, "") + .split("/")[0] +} + +function normalize(input: string) { + const trimmed = input.trim() + if (!trimmed) return + const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}` + const cleaned = withProtocol.replace(/\/+$/, "") + return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1") +} + +async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch, + signal: AbortSignal.timeout(3000), + }) + return sdk.global + .health() + .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) + .catch(() => ({ healthy: false })) +} + +export function DialogSelectServer() { + const navigate = useNavigate() + const dialog = useDialog() + const server = useServer() + const platform = usePlatform() + const [store, setStore] = createStore({ + url: "", + adding: false, + error: "", + status: {} as Record<string, ServerStatus | undefined>, + }) + + const items = createMemo(() => { + const current = server.url + 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]) + + async function refreshHealth() { + const results: Record<string, ServerStatus> = {} + await Promise.all( + items().map(async (url) => { + results[url] = await checkHealth(url, platform.fetch) + }), + ) + setStore("status", reconcile(results)) + } + + createEffect(() => { + items() + refreshHealth() + const interval = setInterval(refreshHealth, 10_000) + onCleanup(() => clearInterval(interval)) + }) + + function select(value: string) { + if (store.status[value]?.healthy === false) return + dialog.close() + server.setActive(value) + navigate("/") + } + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + const value = normalize(store.url) + if (!value) return + + setStore("adding", true) + setStore("error", "") + + const result = await checkHealth(value, platform.fetch) + setStore("adding", false) + + if (!result.healthy) { + setStore("error", "Could not connect to server") + return + } + + setStore("url", "") + select(value) + } + + return ( + <Dialog title="Servers" description="Switch which OpenCode server this app connects to."> + <div class="flex flex-col gap-4 pb-4"> + <List + search={{ placeholder: "Search servers", autofocus: true }} + emptyMessage="No servers yet" + items={items} + key={(x) => x} + current={current()} + onSelect={(x) => { + if (x) select(x) + }} + > + {(i) => ( + <div + class="flex items-center gap-2 min-w-0 flex-1" + classList={{ "opacity-50": store.status[i]?.healthy === false }} + > + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": store.status[i]?.healthy === true, + "bg-icon-critical-base": store.status[i]?.healthy === false, + "bg-border-weak-base": store.status[i] === undefined, + }} + /> + <span class="truncate">{displayName(i)}</span> + <span class="text-text-weak">{store.status[i]?.version}</span> + </div> + )} + </List> + + <div class="mt-6 px-3 flex flex-col gap-1.5"> + <div class="px-3"> + <h3 class="text-14-regular text-text-weak">Add a server</h3> + </div> + <form onSubmit={handleSubmit}> + <div class="flex items-start gap-2"> + <div class="flex-1 min-w-0 h-auto"> + <TextField + type="text" + label="Server URL" + hideLabel + placeholder="http://localhost:4096" + value={store.url} + onChange={(v) => { + setStore("url", v) + setStore("error", "") + }} + validationState={store.error ? "invalid" : "valid"} + error={store.error} + /> + </div> + <Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}> + {store.adding ? "Checking..." : "Add"} + </Button> + </div> + </form> + </div> + </div> + </Dialog> + ) +} diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx index 98d6d6dfd..ac3a39997 100644 --- a/packages/app/src/components/session-lsp-indicator.tsx +++ b/packages/app/src/components/session-lsp-indicator.tsx @@ -1,5 +1,4 @@ import { createMemo, Show } from "solid-js" -import { Icon } from "@opencode-ai/ui/icon" import { useSync } from "@/context/sync" import { Tooltip } from "@opencode-ai/ui/tooltip" @@ -24,12 +23,11 @@ export function SessionLspIndicator() { <Show when={lspStats().total > 0}> <Tooltip placement="top" value={tooltipContent()}> <div class="flex items-center gap-1 px-2 cursor-default select-none"> - <Icon - name="code" - size="small" + <div classList={{ - "text-icon-critical-base": lspStats().hasError, - "text-icon-success-base": !lspStats().hasError && lspStats().connected > 0, + "size-1.5 rounded-full": true, + "bg-icon-critical-base": lspStats().hasError, + "bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0, }} /> <span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span> diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx index 17a6f2e1a..489223b9b 100644 --- a/packages/app/src/components/session-mcp-indicator.tsx +++ b/packages/app/src/components/session-mcp-indicator.tsx @@ -1,6 +1,5 @@ import { createMemo, Show } from "solid-js" import { Button } from "@opencode-ai/ui/button" -import { Icon } from "@opencode-ai/ui/icon" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useSync } from "@/context/sync" import { DialogSelectMcp } from "@/components/dialog-select-mcp" @@ -21,12 +20,11 @@ export function SessionMcpIndicator() { return ( <Show when={mcpStats().total > 0}> <Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}> - <Icon - name="mcp" - size="small" + <div classList={{ - "text-icon-critical-base": mcpStats().failed, - "text-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0, + "size-1.5 rounded-full": true, + "bg-icon-critical-base": mcpStats().failed, + "bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0, }} /> <span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span> diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx index d8a88503f..bfc428fca 100644 --- a/packages/app/src/components/status-bar.tsx +++ b/packages/app/src/components/status-bar.tsx @@ -1,10 +1,14 @@ import { createMemo, Show, type ParentProps } from "solid-js" -import { usePlatform } from "@/context/platform" import { useSync } from "@/context/sync" import { useGlobalSync } from "@/context/global-sync" +import { useServer } from "@/context/server" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Button } from "@opencode-ai/ui/button" +import { DialogSelectServer } from "@/components/dialog-select-server" export function StatusBar(props: ParentProps) { - const platform = usePlatform() + const dialog = useDialog() + const server = useServer() const sync = useSync() const globalSync = useGlobalSync() @@ -19,9 +23,24 @@ export function StatusBar(props: ParentProps) { return ( <div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base"> <div class="flex items-center gap-3"> - <Show when={platform.version}> - <span class="text-12-regular text-text-weak">v{platform.version}</span> - </Show> + <div class="flex items-center gap-1"> + <Button + size="small" + variant="ghost" + onClick={() => { + dialog.show(() => <DialogSelectServer />) + }} + > + <div + classList={{ + "size-1.5 rounded-full": true, + "bg-icon-success-base": server.healthy(), + "bg-icon-critical-base": !server.healthy(), + }} + /> + <span class="text-12-regular text-text-weak">{server.name}</span> + </Button> + </div> <Show when={directoryDisplay()}> <span class="text-12-regular text-text-weak">{directoryDisplay()}</span> </Show> |
