| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- import { Button } from "@kirincode-ai/ui/button"
- import { useDialog } from "@kirincode-ai/ui/context/dialog"
- import { Icon } from "@kirincode-ai/ui/icon"
- import { Switch } from "@kirincode-ai/ui/switch"
- import { Tabs } from "@kirincode-ai/ui/tabs"
- import { showToast } from "@/utils/toast"
- import { useNavigate } from "@solidjs/router"
- import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
- import { createStore } from "solid-js/store"
- import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
- import { useLanguage } from "@/context/language"
- import { usePlatform } from "@/context/platform"
- import { ServerConnection, useServer } from "@/context/server"
- import { useSync } from "@/context/sync"
- import { type ServerHealth } from "@/utils/server-health"
- import { useGlobal } from "@/context/global"
- import { useSettings } from "@/context/settings"
- import { useMcpToggle } from "@/context/mcp"
- 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 listServersByHealth = (
- 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))
- 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 (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 useDefaultServerKey = (
- get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
- ) => {
- const [state, setState] = createStore({
- key: undefined as ServerConnection.Key | undefined,
- tick: 0,
- })
- createEffect(() => {
- state.tick
- let dead = false
- const result = get?.()
- if (!result) {
- setState("key", undefined)
- onCleanup(() => {
- dead = true
- })
- return
- }
- if (result instanceof Promise) {
- void result.then((next) => {
- if (dead) return
- setState("key", next ?? undefined)
- })
- onCleanup(() => {
- dead = true
- })
- return
- }
- setState("key", ServerConnection.Key.make(result))
- onCleanup(() => {
- dead = true
- })
- })
- return {
- key: () => {
- return state.key
- },
- refresh: () => setState("tick", (value) => value + 1),
- }
- }
- type ServerStatusState = {
- servers: () => ServerStatusItem[]
- defaultKey: () => ServerConnection.Key | undefined
- ariaLabel: string
- serversLabel: string
- defaultLabel: string
- manageLabel: string
- onManage: () => void
- }
- type ServerStatusItem = {
- key: ServerConnection.Key
- conn: ServerConnection.Any
- health?: ServerHealth
- blocked: boolean
- active: boolean
- onSelect: () => void
- }
- export function StatusPopoverServerBody() {
- const global = useGlobal()
- const server = useServer()
- const platform = usePlatform()
- const dialog = useDialog()
- const language = useLanguage()
- const navigate = useNavigate()
- let dialogRun = 0
- let dialogDead = false
- onCleanup(() => {
- dialogDead = true
- dialogRun += 1
- })
- const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
- const defaultServer = useDefaultServerKey(platform.getDefaultServer)
- const serverItems = createMemo(() =>
- sortedServers().map((conn) => {
- const key = ServerConnection.key(conn)
- return {
- key,
- conn,
- health: global.servers.health[key],
- blocked: global.servers.health[key]?.healthy === false,
- active: !!server.current && key === ServerConnection.key(server.current),
- onSelect: () => {
- navigate("/")
- queueMicrotask(() => server.setActive(key))
- },
- }
- }),
- )
- return (
- <ServerStatusPopoverView
- state={{
- servers: serverItems,
- defaultKey: defaultServer.key,
- ariaLabel: language.t("status.popover.ariaLabel"),
- serversLabel: language.t("status.popover.tab.servers"),
- defaultLabel: language.t("common.default"),
- manageLabel: language.t("status.popover.action.manageServers"),
- onManage: () => {
- const run = ++dialogRun
- void import("./dialog-select-server").then((x) => {
- if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
- })
- },
- }}
- />
- )
- }
- function ServerStatusPopoverView(props: { state: ServerStatusState }) {
- return (
- <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
- <Tabs
- aria-label={props.state.ariaLabel}
- class="tabs bg-background-strong rounded-xl overflow-hidden"
- data-component="tabs"
- data-active="servers"
- defaultValue="servers"
- variant="alt"
- >
- <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">
- {props.state.servers().length > 0 ? `${props.state.servers().length} ` : ""}
- {props.state.serversLabel}
- </Tabs.Trigger>
- </Tabs.List>
- <Tabs.Content value="servers">
- <ServerStatusList state={props.state} />
- </Tabs.Content>
- </Tabs>
- </div>
- )
- }
- function ServerStatusList(props: { state: ServerStatusState }) {
- return (
- <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={props.state.servers()}>
- {(item) => {
- return (
- <button
- type="button"
- class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
- classList={{
- "hover:bg-surface-raised-base-hover": !item.blocked,
- "cursor-not-allowed": item.blocked,
- }}
- aria-disabled={item.blocked}
- onClick={() => {
- if (item.blocked) return
- item.onSelect()
- }}
- >
- <ServerHealthIndicator health={item.health} />
- <ServerRow
- conn={item.conn}
- dimmed={item.blocked}
- status={item.health}
- 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={item.key === props.state.defaultKey()}>
- <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
- {props.state.defaultLabel}
- </span>
- </Show>
- }
- >
- <div class="flex-1" />
- <Show when={item.active}>
- <Icon name="check" size="small" class="text-icon-weak shrink-0" />
- </Show>
- </ServerRow>
- </button>
- )
- }}
- </For>
- <Button variant="secondary" class="mt-3 self-start h-8 px-3 py-1.5" onClick={props.state.onManage}>
- {props.state.manageLabel}
- </Button>
- </div>
- </div>
- )
- }
- export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
- const sync = useSync()
- const global = useGlobal()
- const server = useServer()
- const platform = usePlatform()
- const dialog = useDialog()
- const language = useLanguage()
- const navigate = useNavigate()
- const settings = useSettings()
- const fail = (err: unknown) => {
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: err instanceof Error ? err.message : String(err),
- })
- }
- createEffect(() => {
- if (!props.shown()) return
- })
- let dialogRun = 0
- let dialogDead = false
- onCleanup(() => {
- dialogDead = true
- dialogRun += 1
- })
- const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health))
- const toggleMcp = useMcpToggle()
- const defaultServer = useDefaultServerKey(platform.getDefaultServer)
- 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)
- const lspItems = createMemo(() => sync().data.lsp ?? [])
- const lspCount = createMemo(() => lspItems().length)
- const plugins = createMemo(() =>
- (sync().data.config.plugin ?? []).map((item) => (typeof item === "string" ? item : item[0])),
- )
- const pluginCount = createMemo(() => plugins().length)
- const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "kirincode.json"))
- return (
- <div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
- <Tabs
- aria-label={language.t("status.popover.ariaLabel")}
- class="tabs bg-background-strong rounded-xl overflow-hidden"
- data-component="tabs"
- data-active={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
- defaultValue={settings.general.newLayoutDesigns() ? "mcp" : "servers"}
- variant="alt"
- >
- <Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
- {!settings.general.newLayoutDesigns() && (
- <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
- {global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""}
- {language.t("status.popover.tab.servers")}
- </Tabs.Trigger>
- )}
- <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
- {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
- {language.t("status.popover.tab.mcp")}
- </Tabs.Trigger>
- <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
- {lspCount() > 0 ? `${lspCount()} ` : ""}
- {language.t("status.popover.tab.lsp")}
- </Tabs.Trigger>
- <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
- {pluginCount() > 0 ? `${pluginCount()} ` : ""}
- {language.t("status.popover.tab.plugins")}
- </Tabs.Trigger>
- </Tabs.List>
- {!settings.general.newLayoutDesigns() && (
- <Tabs.Content value="servers">
- <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()}>
- {(s) => {
- const key = ServerConnection.key(s)
- const blocked = () => global.servers.health[key]?.healthy === false
- return (
- <button
- type="button"
- class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
- classList={{
- "hover:bg-surface-raised-base-hover": !blocked(),
- "cursor-not-allowed": blocked(),
- }}
- aria-disabled={blocked()}
- onClick={() => {
- if (blocked()) return
- navigate("/")
- queueMicrotask(() => server.setActive(key))
- }}
- >
- <ServerHealthIndicator health={global.servers.health[key]} />
- <ServerRow
- conn={s}
- dimmed={blocked()}
- status={global.servers.health[key]}
- 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={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>
- </Show>
- }
- >
- <div class="flex-1" />
- <Show when={server.current && key === ServerConnection.key(server.current)}>
- <Icon name="check" size="small" class="text-icon-weak shrink-0" />
- </Show>
- </ServerRow>
- </button>
- )
- }}
- </For>
- <Button
- variant="secondary"
- class="mt-3 self-start h-8 px-3 py-1.5"
- onClick={() => {
- const run = ++dialogRun
- void import("./dialog-select-server").then((x) => {
- if (dialogDead || dialogRun !== run) return
- dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
- })
- }}
- >
- {language.t("status.popover.action.manageServers")}
- </Button>
- </div>
- </div>
- </Tabs.Content>
- )}
- <Tabs.Content value="mcp">
- <div class="flex flex-col px-2 pb-2">
- <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
- <Show
- when={mcpNames().length > 0}
- fallback={
- <div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
- }
- >
- <For each={mcpNames()}>
- {(name) => {
- const status = () => mcpStatus(name)
- const enabled = () => status() === "connected"
- return (
- <button
- type="button"
- class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
- onClick={() => {
- if (toggleMcp.isPending) return
- toggleMcp.mutate(name)
- }}
- disabled={toggleMcp.isPending && toggleMcp.variables === name}
- >
- <div
- classList={{
- "size-1.5 rounded-full shrink-0": true,
- "bg-icon-success-base": status() === "connected",
- "bg-icon-critical-base": status() === "failed",
- "bg-border-weak-base": status() === "disabled",
- "bg-icon-warning-base":
- status() === "needs_auth" || status() === "needs_client_registration",
- }}
- />
- <span class="flex flex-col min-w-0 flex-1">
- <span class="flex items-center gap-2 min-w-0">
- <span class="text-14-regular text-text-base truncate">{name}</span>
- </span>
- <Show when={status() === "needs_auth"}>
- <span class="text-11-regular text-text-weaker truncate">
- {language.t("mcp.auth.clickToAuthenticate")}
- </span>
- </Show>
- </span>
- <div onClick={(event) => event.stopPropagation()}>
- <Switch
- checked={enabled()}
- disabled={toggleMcp.isPending && toggleMcp.variables === name}
- onChange={() => {
- if (toggleMcp.isPending) return
- toggleMcp.mutate(name)
- }}
- />
- </div>
- </button>
- )
- }}
- </For>
- </Show>
- </div>
- </div>
- </Tabs.Content>
- <Tabs.Content value="lsp">
- <div class="flex flex-col px-2 pb-2">
- <div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
- <Show
- when={lspItems().length > 0}
- fallback={
- <div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
- }
- >
- <For each={lspItems()}>
- {(item) => (
- <div class="flex items-center gap-2 w-full px-2 py-1">
- <div
- classList={{
- "size-1.5 rounded-full shrink-0": true,
- "bg-icon-success-base": item.status === "connected",
- "bg-icon-critical-base": item.status === "error",
- }}
- />
- <span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
- </div>
- )}
- </For>
- </Show>
- </div>
- </div>
- </Tabs.Content>
- <Tabs.Content value="plugins">
- <div class="flex flex-col px-2 pb-2">
- <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">{pluginEmpty()}</div>}
- >
- <For each={plugins()}>
- {(plugin) => (
- <div class="flex items-center gap-2 w-full px-2 py-1">
- <div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
- <span class="text-14-regular text-text-base truncate">{plugin}</span>
- </div>
- )}
- </For>
- </Show>
- </div>
- </div>
- </Tabs.Content>
- </Tabs>
- </div>
- )
- }
|