summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-30 07:24:35 -0600
committerAdam <[email protected]>2025-12-30 07:24:40 -0600
commite0e07c5d48ef0671f63ca8bd9119169ced493fac (patch)
tree9377eab8b3cc5d3c2976ccae2d32e22db46ce816 /packages/app/src/components
parent281f9e623673e6bbfd9a5f9a8f9aae496abc99f2 (diff)
downloadopencode-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.tsx170
-rw-r--r--packages/app/src/components/session-lsp-indicator.tsx10
-rw-r--r--packages/app/src/components/session-mcp-indicator.tsx10
-rw-r--r--packages/app/src/components/status-bar.tsx29
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>