summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components/status-popover.tsx
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-12 09:49:14 -0600
committerGitHub <[email protected]>2026-02-12 09:49:14 -0600
commitff4414bb152acfddb5c0eb073c38bedc1df4ae14 (patch)
tree78381c67d21ef6f089647f6b19e7aa2976840dbc /packages/app/src/components/status-popover.tsx
parent56ad2db02055955f926fda0e4a89055b22ead6f9 (diff)
downloadopencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.tar.gz
opencode-ff4414bb152acfddb5c0eb073c38bedc1df4ae14.zip
chore: refactor packages/app files (#13236)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Frank <[email protected]>
Diffstat (limited to 'packages/app/src/components/status-popover.tsx')
-rw-r--r--packages/app/src/components/status-popover.tsx267
1 files changed, 151 insertions, 116 deletions
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 6e8999017..26ee2d070 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
+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 { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,134 +7,189 @@ 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 { 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 { showToast } from "@opencode-ai/ui/toast"
import { ServerRow } from "@/components/server/server-row"
import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
-export function StatusPopover() {
- const sync = useSync()
- const sdk = useSDK()
- const server = useServer()
- const platform = usePlatform()
- const dialog = useDialog()
- const language = useLanguage()
- const navigate = useNavigate()
+const pollMs = 10_000
- const [store, setStore] = createStore({
- status: {} as Record<string, ServerHealth | undefined>,
- loading: null as string | null,
- defaultServerUrl: undefined as string | undefined,
- })
- const fetcher = platform.fetch ?? globalThis.fetch
+const pluginEmptyMessage = (value: string, file: string): JSXElement => {
+ const parts = value.split(file)
+ if (parts.length === 1) return value
+ return (
+ <>
+ {parts[0]}
+ <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
+ {parts.slice(1).join(file)}
+ </>
+ )
+}
- const servers = 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 listServersByHealth = (
+ list: string[],
+ active: string | undefined,
+ status: Record<string, ServerHealth | undefined>,
+) => {
+ if (!list.length) return list
+ const order = new Map(list.map((url, index) => [url, index] as const))
+ const rank = (value?: ServerHealth) => {
+ if (value?.healthy === true) return 0
+ if (value?.healthy === false) return 2
+ return 1
+ }
+
+ 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 (diff !== 0) return diff
+ return (order.get(a) ?? 0) - (order.get(b) ?? 0)
})
+}
- const sortedServers = createMemo(() => {
+const useServerHealth = (servers: Accessor<string[]>, fetcher: typeof fetch) => {
+ const [status, setStatus] = createStore({} as Record<string, ServerHealth | undefined>)
+
+ createEffect(() => {
const list = servers()
- if (!list.length) return list
- const active = server.url
- const order = new Map(list.map((url, index) => [url, index] as const))
- const rank = (value?: ServerHealth) => {
- if (value?.healthy === true) return 0
- if (value?.healthy === false) return 2
- return 1
+ let dead = false
+
+ const refresh = async () => {
+ const results: Record<string, ServerHealth> = {}
+ await Promise.all(
+ list.map(async (url) => {
+ results[url] = await checkServerHealth(url, fetcher)
+ }),
+ )
+ if (dead) return
+ setStatus(reconcile(results))
}
- 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])
- if (diff !== 0) return diff
- return (order.get(a) ?? 0) - (order.get(b) ?? 0)
+
+ void refresh()
+ const id = setInterval(() => void refresh(), pollMs)
+ onCleanup(() => {
+ dead = true
+ clearInterval(id)
})
})
- async function refreshHealth() {
- const results: Record<string, ServerHealth> = {}
- await Promise.all(
- servers().map(async (url) => {
- results[url] = await checkServerHealth(url, fetcher)
- }),
- )
- setStore("status", reconcile(results))
- }
+ return status
+}
+
+const useDefaultServerUrl = (
+ get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
+) => {
+ const [url, setUrl] = createSignal<string | undefined>()
+ const [tick, setTick] = createSignal(0)
createEffect(() => {
- servers()
- refreshHealth()
- const interval = setInterval(refreshHealth, 10_000)
- onCleanup(() => clearInterval(interval))
+ tick()
+ let dead = false
+ const result = get?.()
+ if (!result) {
+ setUrl(undefined)
+ onCleanup(() => {
+ dead = true
+ })
+ return
+ }
+
+ if (result instanceof Promise) {
+ void result.then((next) => {
+ if (dead) return
+ setUrl(next ? normalizeServerUrl(next) : undefined)
+ })
+ onCleanup(() => {
+ dead = true
+ })
+ return
+ }
+
+ setUrl(normalizeServerUrl(result))
+ onCleanup(() => {
+ dead = true
+ })
})
- const mcpItems = createMemo(() =>
- Object.entries(sync.data.mcp ?? {})
- .map(([name, status]) => ({ name, status: status.status }))
- .sort((a, b) => a.name.localeCompare(b.name)),
- )
+ return { url, refresh: () => setTick((value) => value + 1) }
+}
- const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length)
+const useMcpToggle = (input: {
+ sync: ReturnType<typeof useSync>
+ sdk: ReturnType<typeof useSDK>
+ language: ReturnType<typeof useLanguage>
+}) => {
+ const [loading, setLoading] = createSignal<string | null>(null)
- const toggleMcp = async (name: string) => {
- if (store.loading) return
- setStore("loading", name)
+ const toggle = async (name: string) => {
+ if (loading()) return
+ setLoading(name)
try {
- const status = sync.data.mcp[name]
- await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
- const result = await sdk.client.mcp.status()
- if (result.data) sync.set("mcp", result.data)
+ const status = input.sync.data.mcp[name]
+ await (status?.status === "connected"
+ ? input.sdk.client.mcp.disconnect({ name })
+ : input.sdk.client.mcp.connect({ name }))
+ const result = await input.sdk.client.mcp.status()
+ if (result.data) input.sync.set("mcp", result.data)
} catch (err) {
showToast({
variant: "error",
- title: language.t("common.requestFailed"),
+ title: input.language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
} finally {
- setStore("loading", null)
+ setLoading(null)
}
}
+ return { loading, toggle }
+}
+
+export function StatusPopover() {
+ const sync = useSync()
+ const sdk = useSDK()
+ const server = useServer()
+ const platform = usePlatform()
+ const dialog = useDialog()
+ const language = useLanguage()
+ const navigate = useNavigate()
+
+ const fetcher = platform.fetch ?? globalThis.fetch
+ const servers = createMemo(() => {
+ const current = server.url
+ const list = server.list
+ if (!current) return list
+ if (!list.includes(current)) return [current, ...list]
+ return [current, ...list.filter((item) => item !== current)]
+ })
+ const health = useServerHealth(servers, fetcher)
+ const sortedServers = createMemo(() => listServersByHealth(servers(), server.url, health))
+ const mcp = useMcpToggle({ sync, sdk, language })
+ const defaultServer = useDefaultServerUrl(platform.getDefaultServerUrl)
+ const mcpItems = createMemo(() =>
+ Object.entries(sync.data.mcp ?? {})
+ .map(([name, status]) => ({ name, status: status.status }))
+ .sort((a, b) => a.name.localeCompare(b.name)),
+ )
+ const mcpConnected = createMemo(() => mcpItems().filter((item) => item.status === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => sync.data.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length)
-
+ const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
const overallHealthy = createMemo(() => {
const serverHealthy = server.healthy() === true
- const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled")
+ const anyMcpIssue = mcpItems().some((item) => item.status !== "connected" && item.status !== "disabled")
return serverHealthy && !anyMcpIssue
})
- const serverCount = createMemo(() => sortedServers().length)
-
- const refreshDefaultServerUrl = () => {
- const result = platform.getDefaultServerUrl?.()
- if (!result) {
- setStore("defaultServerUrl", undefined)
- return
- }
- if (result instanceof Promise) {
- result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined))
- return
- }
- setStore("defaultServerUrl", normalizeServerUrl(result))
- }
-
- createEffect(() => {
- refreshDefaultServerUrl()
- })
-
return (
<Popover
triggerAs={Button}
@@ -173,7 +228,7 @@ export function StatusPopover() {
>
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
- {serverCount() > 0 ? `${serverCount()} ` : ""}
+ {sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
{language.t("status.popover.tab.servers")}
</Tabs.Trigger>
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
@@ -195,11 +250,7 @@ export function StatusPopover() {
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<For each={sortedServers()}>
{(url) => {
- const isActive = () => url === server.url
- const isDefault = () => url === store.defaultServerUrl
- const status = () => store.status[url]
- const isBlocked = () => status()?.healthy === false
-
+ const isBlocked = () => health[url]?.healthy === false
return (
<button
type="button"
@@ -217,13 +268,13 @@ export function StatusPopover() {
>
<ServerRow
url={url}
- status={status()}
+ status={health[url]}
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={isDefault()}>
+ <Show when={url === defaultServer.url()}>
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
{language.t("common.default")}
</span>
@@ -231,7 +282,7 @@ export function StatusPopover() {
}
>
<div class="flex-1" />
- <Show when={isActive()}>
+ <Show when={url === server.url}>
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
</Show>
</ServerRow>
@@ -243,7 +294,7 @@ export function StatusPopover() {
<Button
variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5"
- onClick={() => dialog.show(() => <DialogSelectServer />, refreshDefaultServerUrl)}
+ onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)}
>
{language.t("status.popover.action.manageServers")}
</Button>
@@ -269,8 +320,8 @@ export function StatusPopover() {
<button
type="button"
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
- onClick={() => toggleMcp(item.name)}
- disabled={store.loading === item.name}
+ onClick={() => mcp.toggle(item.name)}
+ disabled={mcp.loading() === item.name}
>
<div
classList={{
@@ -286,8 +337,8 @@ export function StatusPopover() {
<div onClick={(event) => event.stopPropagation()}>
<Switch
checked={enabled()}
- disabled={store.loading === item.name}
- onChange={() => toggleMcp(item.name)}
+ disabled={mcp.loading() === item.name}
+ onChange={() => mcp.toggle(item.name)}
/>
</div>
</button>
@@ -334,23 +385,7 @@ export function StatusPopover() {
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
<Show
when={plugins().length > 0}
- fallback={
- <div class="text-14-regular text-text-base text-center my-auto">
- {(() => {
- const value = language.t("dialog.plugins.empty")
- const file = "opencode.json"
- const parts = value.split(file)
- if (parts.length === 1) return value
- return (
- <>
- {parts[0]}
- <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
- {parts.slice(1).join(file)}
- </>
- )
- })()}
- </div>
- }
+ fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
>
<For each={plugins()}>
{(plugin) => (