diff options
| author | Adam <[email protected]> | 2026-02-12 09:49:14 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-12 09:49:14 -0600 |
| commit | ff4414bb152acfddb5c0eb073c38bedc1df4ae14 (patch) | |
| tree | 78381c67d21ef6f089647f6b19e7aa2976840dbc /packages/app/src/components/status-popover.tsx | |
| parent | 56ad2db02055955f926fda0e4a89055b22ead6f9 (diff) | |
| download | opencode-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.tsx | 267 |
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) => ( |
