diff options
| author | OpeOginni <[email protected]> | 2026-01-24 19:03:36 +0100 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-01-24 12:03:36 -0600 |
| commit | 67ea21b55a281d6feb00820f1fec94da50165678 (patch) | |
| tree | 44fc751afe3120de59e5e8adac17b1b40d519bb0 /packages | |
| parent | f4cf3f4976b8e8e60b90098bba57c11ffa115a6a (diff) | |
| download | opencode-67ea21b55a281d6feb00820f1fec94da50165678.tar.gz opencode-67ea21b55a281d6feb00820f1fec94da50165678.zip | |
feat(web): implement new server management for web and desktop (#8513)
Diffstat (limited to 'packages')
25 files changed, 1103 insertions, 359 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d5009c8d1..6eb959c34 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) { ) } -export function AppInterface(props: { defaultUrl?: string }) { +export function AppInterface(props: { defaultUrl?: string; }) { const defaultServerUrl = () => { if (props.defaultUrl) return props.defaultUrl if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 4466fdde1..e62aa93be 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,23 +1,47 @@ -import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" +import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } 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 { IconButton } from "@opencode-ai/ui/icon-button" +import { TextField } from "@opencode-ai/ui/text-field" import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { useNavigate } from "@solidjs/router" import { useLanguage } from "@/context/language" +import { Popover } from "@opencode-ai/ui/popover" +import { useGlobalSDK } from "@/context/global-sdk" type ServerStatus = { healthy: boolean; version?: string } -async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> { +interface AddRowProps { + value: string + placeholder: string + adding: boolean + error: string + status: boolean | undefined + onChange: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + onBlur: () => void +} + +interface EditRowProps { + value: string + placeholder: string + busy: boolean + error: string + status: boolean | undefined + onChange: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + onBlur: () => void +} + +async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> { const sdk = createOpencodeClient({ baseUrl: url, - fetch, + fetch: platform.fetch, signal: AbortSignal.timeout(3000), }) return sdk.global @@ -26,21 +50,139 @@ async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promis .catch(() => ({ healthy: false })) } +function AddRow(props: AddRowProps) { + return ( + <div class="flex items-center gap-3 px-4 min-w-0 flex-1"> + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": props.status === true, + "bg-icon-critical-base": props.status === false, + "bg-border-weak-base": props.status === undefined, + }} + /> + <div class="flex-1 min-w-0"> + <TextField + type="text" + hideLabel + placeholder={props.placeholder} + value={props.value} + autofocus + validationState={props.error ? "invalid" : "valid"} + error={props.error} + disabled={props.adding} + onChange={props.onChange} + onKeyDown={props.onKeyDown} + onBlur={props.onBlur} + /> + </div> + </div> + ) +} + +function EditRow(props: EditRowProps) { + return ( + <div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}> + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": props.status === true, + "bg-icon-critical-base": props.status === false, + "bg-border-weak-base": props.status === undefined, + }} + /> + <div class="flex-1 min-w-0"> + <TextField + type="text" + hideLabel + placeholder={props.placeholder} + value={props.value} + autofocus + validationState={props.error ? "invalid" : "valid"} + error={props.error} + disabled={props.busy} + onChange={props.onChange} + onKeyDown={props.onKeyDown} + onBlur={props.onBlur} + /> + </div> + </div> + ) +} + export function DialogSelectServer() { const navigate = useNavigate() const dialog = useDialog() const server = useServer() const platform = usePlatform() + const globalSDK = useGlobalSDK() const language = useLanguage() const [store, setStore] = createStore({ - url: "", - adding: false, - error: "", status: {} as Record<string, ServerStatus | undefined>, + addServer: { + url: "", + adding: false, + error: "", + showForm: false, + status: undefined as boolean | undefined, + }, + editServer: { + id: undefined as string | undefined, + value: "", + error: "", + busy: false, + status: undefined as boolean | undefined, + }, }) const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.()) const isDesktop = platform.platform === "desktop" + const looksComplete = (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) return false + const host = normalized.replace(/^https?:\/\//, "").split("/")[0] + if (!host) return false + if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true + return host.includes(".") || host.includes(":") + } + + const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => { + setStatus(undefined) + if (!looksComplete(value)) return + const normalized = normalizeServerUrl(value) + if (!normalized) return + const result = await checkHealth(normalized, platform) + setStatus(result.healthy) + } + + const resetAdd = () => { + setStore("addServer", { + url: "", + error: "", + showForm: false, + status: undefined, + }) + } + + const resetEdit = () => { + setStore("editServer", { + id: undefined, + value: "", + error: "", + status: undefined, + busy: false, + }) + } + + const replaceServer = (original: string, next: string) => { + const active = server.url + const nextActive = active === original ? next : active + + server.add(next) + if (nextActive) server.setActive(nextActive) + server.remove(original) + } + const items = createMemo(() => { const current = server.url const list = server.list @@ -74,7 +216,7 @@ export function DialogSelectServer() { const results: Record<string, ServerStatus> = {} await Promise.all( items().map(async (url) => { - results[url] = await checkHealth(url, platform.fetch) + results[url] = await checkHealth(url, platform) }), ) setStore("status", reconcile(results)) @@ -87,7 +229,7 @@ export function DialogSelectServer() { onCleanup(() => clearInterval(interval)) }) - function select(value: string, persist?: boolean) { + async function select(value: string, persist?: boolean) { if (!persist && store.status[value]?.healthy === false) return dialog.close() if (persist) { @@ -99,24 +241,101 @@ export function DialogSelectServer() { navigate("/") } - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - const value = normalizeServerUrl(store.url) - if (!value) return + const handleAddChange = (value: string) => { + if (store.addServer.adding) return + setStore("addServer", { url: value, error: "" }) + void previewStatus(value, (next) => setStore("addServer", { status: next })) + } + + const scrollListToBottom = () => { + const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]') + if (!scroll) return + requestAnimationFrame(() => { + scroll.scrollTop = scroll.scrollHeight + }) + } + + const handleEditChange = (value: string) => { + if (store.editServer.busy) return + setStore("editServer", { value, error: "" }) + void previewStatus(value, (next) => setStore("editServer", { status: next })) + } + + async function handleAdd(value: string) { + if (store.addServer.adding) return + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetAdd() + return + } - setStore("adding", true) - setStore("error", "") + setStore("addServer", { adding: true, error: "" }) - const result = await checkHealth(value, platform.fetch) - setStore("adding", false) + const result = await checkHealth(normalized, platform) + setStore("addServer", { adding: false }) if (!result.healthy) { - setStore("error", language.t("dialog.server.add.error")) + setStore("addServer", { error: language.t("dialog.server.add.error") }) return } - setStore("url", "") - select(value, true) + resetAdd() + await select(normalized, true) + } + + async function handleEdit(original: string, value: string) { + if (store.editServer.busy) return + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetEdit() + return + } + + if (normalized === original) { + resetEdit() + return + } + + setStore("editServer", { busy: true, error: "" }) + + const result = await checkHealth(normalized, platform) + setStore("editServer", { busy: false }) + + if (!result.healthy) { + setStore("editServer", { error: language.t("dialog.server.add.error") }) + return + } + + replaceServer(original, normalized) + + resetEdit() + } + + const handleAddKey = (event: KeyboardEvent) => { + event.stopPropagation() + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + handleAdd(store.addServer.url) + } + + const blurAdd = () => { + if (!store.addServer.url.trim()) { + resetAdd() + return + } + handleAdd(store.addServer.url) + } + + const handleEditKey = (event: KeyboardEvent, original: string) => { + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + resetEdit() + return + } + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + handleEdit(original, store.editServer.value) } async function handleRemove(url: string) { @@ -124,125 +343,185 @@ export function DialogSelectServer() { } return ( - <Dialog title={language.t("dialog.server.title")} description={language.t("dialog.server.description")}> - <div class="flex flex-col gap-4 pb-4"> + <Dialog title={language.t("dialog.server.title")}> + <div class="flex flex-col gap-2 pb-4"> <List search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: true }} emptyMessage={language.t("dialog.server.empty")} items={sortedItems} key={(x) => x} - current={current()} onSelect={(x) => { if (x) select(x) }} + divider={true} + class="[&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:py-3" + add={ + store.addServer.showForm + ? { + render: () => ( + <AddRow + value={store.addServer.url} + placeholder={language.t("dialog.server.add.placeholder")} + adding={store.addServer.adding} + error={store.addServer.error} + status={store.addServer.status} + onChange={handleAddChange} + onKeyDown={handleAddKey} + onBlur={blurAdd} + /> + ), + } + : undefined + } > - {(i) => ( - <div class="flex items-center gap-2 min-w-0 flex-1 group/item"> - <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">{serverDisplayName(i)}</span> - <span class="text-text-weak">{store.status[i]?.version}</span> + {(i) => { + const [popoverOpen, setPopoverOpen] = createSignal(false) + return ( + <div class="flex items-center gap-3 min-w-0 flex-1 group/item"> + <Show + when={store.editServer.id !== i} + fallback={ + <EditRow + value={store.editServer.value} + placeholder={language.t("dialog.server.add.placeholder")} + busy={store.editServer.busy} + error={store.editServer.error} + status={store.editServer.status} + onChange={handleEditChange} + onKeyDown={(event) => handleEditKey(event, i)} + onBlur={() => handleEdit(i, store.editServer.value)} + /> + } + > + <div + class="flex items-center gap-3 px-4 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">{serverDisplayName(i)}</span> + <Show when={store.status[i]?.version}> + <span class="text-text-weak text-14-regular">{store.status[i]?.version}</span> + </Show> + <Show when={defaultUrl() === i}> + <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> + {language.t("dialog.server.status.default")} + </span> + </Show> + </div> + </Show> + <Show when={store.editServer.id !== i}> + <div class="flex items-center justify-center gap-5 px-4"> + <Show when={current() === i}> + <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> + </Show> + + <div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}> + <Popover + open={popoverOpen()} + onOpenChange={setPopoverOpen} + placement="bottom-start" + trigger={ + <IconButton + icon="dot-grid" + variant="ghost" + class="bg-transparent transition-opacity shrink-0 hover:scale-110 size-8" + onPointerDown={(event: PointerEvent) => event.stopPropagation()} + /> + } + class="w-max !min-w-fit !max-w-none" + > + <div class="flex flex-col gap-1"> + <Button + variant="ghost" + size="normal" + class="justify-start text-md" + onClick={(e: MouseEvent) => { + e.stopPropagation() + setPopoverOpen(false) + setStore("editServer", { + id: i, + value: i, + error: "", + status: store.status[i]?.healthy, + }) + }} + > + {language.t("dialog.server.menu.edit")} + </Button> + <Show when={isDesktop && defaultUrl() !== i}> + <Button + variant="ghost" + size="normal" + class="justify-start text-md" + onClick={async (e: MouseEvent) => { + e.stopPropagation() + setPopoverOpen(false) + await platform.setDefaultServerUrl?.(i) + defaultUrlActions.refetch(i) + }} + > + {language.t("dialog.server.menu.default")} + </Button> + </Show> + <Show when={isDesktop && defaultUrl() === i}> + <Button + variant="ghost" + size="normal" + class="justify-start text-md" + onClick={async (e: MouseEvent) => { + e.stopPropagation() + setPopoverOpen(false) + await platform.setDefaultServerUrl?.(null) + defaultUrlActions.refetch(null) + }} + > + {language.t("dialog.server.menu.defaultRemove")} + </Button> + </Show> + <div class="h-px bg-border-weak-base my-1" /> + <Button + variant="ghost" + size="normal" + class="justify-start text-md text-text-on-critical-base hover:bg-surface-critical-weak" + onClick={(e: MouseEvent) => { + e.stopPropagation() + setPopoverOpen(false) + handleRemove(i) + }} + > + {language.t("dialog.server.menu.delete")} + </Button> + </div> + </Popover> + </div> + </div> + </Show> </div> - <Show when={current() !== i && server.list.includes(i)}> - <IconButton - icon="circle-x" - variant="ghost" - class="bg-transparent transition-opacity shrink-0 hover:scale-110" - aria-label={language.t("dialog.server.action.remove")} - onClick={(e) => { - e.stopPropagation() - handleRemove(i) - }} - /> - </Show> - </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">{language.t("dialog.server.add.title")}</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={language.t("dialog.server.add.url")} - hideLabel - placeholder={language.t("dialog.server.add.placeholder")} - 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 ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")} - </Button> - </div> - </form> + <div class="px-6"> + <Button + variant="secondary" + icon="plus-small" + size="large" + onClick={() => { + setStore("addServer", { showForm: true, url: "", error: "" }) + scrollListToBottom() + }} + class="px-3 py-4" + > + {store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")} + </Button> </div> - - <Show when={isDesktop}> - <div class="mt-6 px-3 flex flex-col gap-1.5"> - <div class="px-3"> - <h3 class="text-14-regular text-text-weak">{language.t("dialog.server.default.title")}</h3> - <p class="text-12-regular text-text-weak mt-1">{language.t("dialog.server.default.description")}</p> - </div> - <div class="flex items-center gap-2 px-3 py-2"> - <Show - when={defaultUrl()} - fallback={ - <Show - when={server.url} - fallback={ - <span class="text-14-regular text-text-weak">{language.t("dialog.server.default.none")}</span> - } - > - <Button - variant="secondary" - size="small" - onClick={async () => { - await platform.setDefaultServerUrl?.(server.url) - defaultUrlActions.refetch(server.url) - }} - > - {language.t("dialog.server.default.set")} - </Button> - </Show> - } - > - <div class="flex items-center gap-2 flex-1 min-w-0"> - <span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span> - </div> - <Button - variant="ghost" - size="small" - onClick={async () => { - await platform.setDefaultServerUrl?.(null) - defaultUrlActions.refetch() - }} - > - {language.t("dialog.server.default.clear")} - </Button> - </Show> - </div> - </div> - </Show> </div> </Dialog> ) diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx deleted file mode 100644 index dab92920e..000000000 --- a/packages/app/src/components/session-lsp-indicator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { createMemo, Show } from "solid-js" -import { useSync } from "@/context/sync" -import { useLanguage } from "@/context/language" -import { Tooltip } from "@opencode-ai/ui/tooltip" - -export function SessionLspIndicator() { - const sync = useSync() - const language = useLanguage() - - const lspStats = createMemo(() => { - const lsp = sync.data.lsp ?? [] - const connected = lsp.filter((s) => s.status === "connected").length - const hasError = lsp.some((s) => s.status === "error") - const total = lsp.length - return { connected, hasError, total } - }) - - const tooltipContent = createMemo(() => { - const lsp = sync.data.lsp ?? [] - if (lsp.length === 0) return language.t("lsp.tooltip.none") - return lsp.map((s) => s.name).join(", ") - }) - - return ( - <Show when={lspStats().total > 0}> - <Tooltip placement="top" value={tooltipContent()}> - <div class="flex items-center gap-1 px-2 cursor-default select-none"> - <div - classList={{ - "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"> - {language.t("lsp.label.connected", { count: lspStats().connected })} - </span> - </div> - </Tooltip> - </Show> - ) -} diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx deleted file mode 100644 index 489223b9b..000000000 --- a/packages/app/src/components/session-mcp-indicator.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createMemo, Show } from "solid-js" -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useSync } from "@/context/sync" -import { DialogSelectMcp } from "@/components/dialog-select-mcp" - -export function SessionMcpIndicator() { - const sync = useSync() - const dialog = useDialog() - - const mcpStats = createMemo(() => { - const mcp = sync.data.mcp ?? {} - const entries = Object.entries(mcp) - const enabled = entries.filter(([, status]) => status.status === "connected").length - const failed = entries.some(([, status]) => status.status === "failed") - const total = entries.length - return { enabled, failed, total } - }) - - return ( - <Show when={mcpStats().total > 0}> - <Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}> - <div - classList={{ - "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> - </Button> - </Show> - ) -} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 1e06e8ed6..4b7f9f4ad 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -20,14 +20,13 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" import { Keybind } from "@opencode-ai/ui/keybind" +import { StatusPopover } from "../status-popover" export function SessionHeader() { const globalSDK = useGlobalSDK() const layout = useLayout() const params = useParams() const command = useCommand() - // const server = useServer() - // const dialog = useDialog() const sync = useSync() const platform = usePlatform() const language = useLanguage() @@ -154,96 +153,7 @@ export function SessionHeader() { {(mount) => ( <Portal mount={mount()}> <div class="flex items-center gap-3"> - {/* <div class="hidden md: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() === true, */} - {/* "bg-icon-critical-base": server.healthy() === false, */} - {/* "bg-border-weak-base": server.healthy() === undefined, */} - {/* }} */} - {/* /> */} - {/* <Icon name="server" size="small" class="text-icon-weak" /> */} - {/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */} - {/* </Button> */} - {/* <SessionLspIndicator /> */} - {/* <SessionMcpIndicator /> */} - {/* </div> */} - <div class="flex items-center gap-1"> - <div class="hidden md:block shrink-0"> - <TooltipKeybind - title={language.t("command.review.toggle")} - keybind={command.keybind("review.toggle")} - > - <Button - variant="ghost" - class="group/review-toggle size-6 p-0" - onClick={() => view().reviewPanel.toggle()} - aria-label={language.t("command.review.toggle")} - aria-expanded={view().reviewPanel.opened()} - aria-controls="review-panel" - tabIndex={showReview() ? 0 : -1} - > - <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - size="small" - name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"} - class="group-hover/review-toggle:hidden" - /> - <Icon - size="small" - name="layout-right-partial" - class="hidden group-hover/review-toggle:inline-block" - /> - <Icon - size="small" - name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"} - class="hidden group-active/review-toggle:inline-block" - /> - </div> - </Button> - </TooltipKeybind> - </div> - <TooltipKeybind - class="hidden md:block shrink-0" - title={language.t("command.terminal.toggle")} - keybind={command.keybind("terminal.toggle")} - > - <Button - variant="ghost" - class="group/terminal-toggle size-6 p-0" - onClick={() => view().terminal.toggle()} - aria-label={language.t("command.terminal.toggle")} - aria-expanded={view().terminal.opened()} - aria-controls="terminal-panel" - > - <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - size="small" - name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"} - class="group-hover/terminal-toggle:hidden" - /> - <Icon - size="small" - name="layout-bottom-partial" - class="hidden group-hover/terminal-toggle:inline-block" - /> - <Icon - size="small" - name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"} - class="hidden group-active/terminal-toggle:inline-block" - /> - </div> - </Button> - </TooltipKeybind> - </div> + <StatusPopover /> <Show when={showShare()}> <div class="flex items-center"> <Popover @@ -253,9 +163,11 @@ export function SessionHeader() { ? language.t("session.share.popover.description.shared") : language.t("session.share.popover.description.unshared") } + gutter={8} triggerAs={Button} triggerProps={{ variant: "secondary", + class: "rounded-sm w-[60px] h-[24px]", classList: { "rounded-r-none": shareUrl() !== undefined }, style: { scale: 1 }, }} @@ -308,7 +220,7 @@ export function SessionHeader() { </Show> </div> </Popover> - <Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}> + <Show when={shareUrl()} fallback={<div aria-hidden="true" />}> <Tooltip value={ state.copied @@ -334,6 +246,70 @@ export function SessionHeader() { </Show> </div> </Show> + <div class="hidden md:block shrink-0"> + <TooltipKeybind + title={language.t("command.terminal.toggle")} + keybind={command.keybind("terminal.toggle")} + > + <Button + variant="ghost" + class="group/terminal-toggle size-5 p-0" + onClick={() => view().terminal.toggle()} + aria-label={language.t("command.terminal.toggle")} + aria-expanded={view().terminal.opened()} + aria-controls="terminal-panel" + > + <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> + <Icon + size="small" + name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"} + class="group-hover/terminal-toggle:hidden" + /> + <Icon + size="small" + name="layout-bottom-partial" + class="hidden group-hover/terminal-toggle:inline-block" + /> + <Icon + size="small" + name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"} + class="hidden group-active/terminal-toggle:inline-block" + /> + </div> + </Button> + </TooltipKeybind> + </div> + <div class="hidden md:block shrink-0"> + <TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}> + <Button + variant="ghost" + class="group/review-toggle size-5 p-0" + onClick={() => view().reviewPanel.toggle()} + aria-label={language.t("command.review.toggle")} + aria-expanded={view().reviewPanel.opened()} + aria-controls="review-panel" + tabIndex={showReview() ? 0 : -1} + > + <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> + <Icon + size="small" + name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"} + class="group-hover/review-toggle:hidden" + /> + <Icon + size="small" + name="layout-right-partial" + class="hidden group-hover/review-toggle:inline-block" + /> + <Icon + size="small" + name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"} + class="hidden group-active/review-toggle:inline-block" + /> + </div> + </Button> + </TooltipKeybind> + </div> </div> </Portal> )} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx new file mode 100644 index 000000000..5b65f62bc --- /dev/null +++ b/packages/app/src/components/status-popover.tsx @@ -0,0 +1,364 @@ +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { useNavigate } from "@solidjs/router" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Popover } from "@opencode-ai/ui/popover" +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 { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { DialogSelectServer } from "./dialog-select-server" + +type ServerStatus = { healthy: boolean; version?: string } + +async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch: platform.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 StatusPopover() { + const sync = useSync() + const sdk = useSDK() + const server = useServer() + const platform = usePlatform() + const dialog = useDialog() + const language = useLanguage() + const navigate = useNavigate() + + const [loading, setLoading] = createSignal<string | null>(null) + const [store, setStore] = createStore({ + status: {} as Record<string, ServerStatus | undefined>, + }) + + 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 sortedServers = createMemo(() => { + 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?: ServerStatus) => { + 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(store.status[a]) - rank(store.status[b]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) + }) + + async function refreshHealth() { + const results: Record<string, ServerStatus> = {} + await Promise.all( + servers().map(async (url) => { + results[url] = await checkHealth(url, platform) + }), + ) + setStore("status", reconcile(results)) + } + + createEffect(() => { + servers() + refreshHealth() + const interval = setInterval(refreshHealth, 10_000) + onCleanup(() => clearInterval(interval)) + }) + + 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((i) => i.status === "connected").length) + + const toggleMcp = async (name: string) => { + if (loading()) return + setLoading(name) + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + setLoading(null) + } + + const lspItems = createMemo(() => sync.data.lsp ?? []) + const lspCount = createMemo(() => lspItems().length) + const plugins = createMemo(() => sync.data.config.plugin ?? []) + const pluginCount = createMemo(() => plugins().length) + + const overallHealthy = createMemo(() => { + const serverHealthy = server.healthy() === true + const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled") + return serverHealthy && !anyMcpIssue + }) + + const serverCount = createMemo(() => sortedServers().length) + + const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>() + + createEffect(() => { + const result = platform.getDefaultServerUrl?.() + if (result instanceof Promise) { + result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined)) + return + } + if (result) setDefaultServerUrl(normalizeServerUrl(result)) + }) + + return ( + <Popover + triggerAs={Button} + triggerProps={{ + variant: "ghost", + class: "rounded-sm w-[75px] h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none", + style: { scale: 1 }, + }} + trigger={ + <div class="flex items-center gap-1.5"> + <div + classList={{ + "size-1.5 rounded-full": true, + "bg-icon-success-base": overallHealthy(), + "bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined, + "bg-border-weak-base": server.healthy() === undefined, + }} + /> + <span class="text-12-regular text-text-strong">Status</span> + </div> + } + class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] mx-5 bg-transparent border-0 shadow-none rounded-xl" + gutter={8} + > + <div class="flex items-center gap-1 w-[360px] border border-border-weak-base rounded-xl"> + <Tabs + aria-label="Server Configurations" + class="tabs" + data-component="tabs" + data-active="servers" + defaultValue="servers" + variant="alt" + style={{ + "background-color": "var(--background-strong)", + "border-radius": "12px", + overflow: "hidden", + }} + > + <Tabs.List + data-slot="tablist" + style={{ + "background-color": "transparent", + "border-bottom": "none", + padding: "8px 16px 0", + gap: "16px", + height: "40px", + }} + > + <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular"> + {serverCount() > 0 ? `${serverCount()} ` : ""}Servers + </Tabs.Trigger> + <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular"> + {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}MCP + </Tabs.Trigger> + <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular"> + {lspCount() > 0 ? `${lspCount()} ` : ""}LSP + </Tabs.Trigger> + <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular"> + {pluginCount() > 0 ? `${pluginCount()} ` : ""}Plugins + </Tabs.Trigger> + </Tabs.List> + + <Tabs.Content value="servers"> + <div class="flex flex-col px-2 pb-2"> + <div class="flex flex-col p-2 bg-background-base"> + <For each={sortedServers()}> + {(url) => { + const isActive = () => url === server.url + const isDefault = () => url === defaultServerUrl() + const status = () => store.status[url] + const isBlocked = () => status()?.healthy === false + return ( + <button + type="button" + class="flex items-center gap-2 w-full px-2 py-1 rounded-md transition-colors text-left" + classList={{ + "opacity-50": isBlocked(), + "hover:bg-surface-raised-base-hover": !isBlocked(), + "cursor-not-allowed": isBlocked(), + }} + aria-disabled={isBlocked()} + onClick={() => { + if (isBlocked()) return + server.setActive(url) + navigate("/") + }} + > + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": status()?.healthy === true, + "bg-icon-critical-base": status()?.healthy === false, + "bg-border-weak-base": status() === undefined, + }} + /> + <span class="text-14-regular text-text-base truncate">{serverDisplayName(url)}</span> + <Show when={status()?.version}> + <span class="text-12-regular text-text-weak">{status()?.version}</span> + </Show> + <Show when={isDefault()}> + <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> + Default + </span> + </Show> + <div class="flex-1" /> + <Show when={isActive()}> + <Icon name="check" size="small" class="text-icon-weak shrink-0" /> + </Show> + </button> + ) + }} + </For> + + <Button + variant="secondary" + class="mt-2 self-start" + onClick={() => dialog.show(() => <DialogSelectServer />)} + > + Manage servers + </Button> + </div> + </div> + </Tabs.Content> + + <Tabs.Content value="mcp"> + <div class="flex flex-col px-2 pb-2"> + <div class="flex flex-col p-2 bg-background-base"> + <Show + when={mcpItems().length > 0} + fallback={ + <div class="text-14-regular text-text-weak text-center py-4">No MCP servers configured</div> + } + > + <For each={mcpItems()}> + {(item) => { + const enabled = () => item.status === "connected" + return ( + <button + type="button" + class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" + onClick={() => toggleMcp(item.name)} + disabled={loading() === item.name} + > + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": item.status === "connected", + "bg-icon-critical-base": item.status === "failed", + "bg-border-weak-base": item.status === "disabled", + "bg-icon-warning-base": + item.status === "needs_auth" || item.status === "needs_client_registration", + }} + /> + <span class="text-14-regular text-text-base truncate flex-1">{item.name}</span> + <div onClick={(event) => event.stopPropagation()}> + <Switch + checked={enabled()} + disabled={loading() === item.name} + onChange={() => toggleMcp(item.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-2 bg-background-base"> + <Show + when={lspItems().length > 0} + fallback={ + <div class="text-14-regular text-text-weak text-center py-4"> + LSPs auto-detected from file types + </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-2 bg-background-base"> + <Show + when={plugins().length > 0} + fallback={ + <div class="text-14-regular text-text-weak text-center py-4"> + Plugins configured in{" "} + <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm">opencode.json</code> + </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> + </Popover> + ) +} diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 107657092..9c2a519f5 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -211,4 +211,4 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }, } }, -}) +})
\ No newline at end of file diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index e9517ed4c..3a1912345 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -223,6 +223,9 @@ export const dict = { "dialog.mcp.description": "{{enabled}} من {{total}} مفعل", "dialog.mcp.empty": "لم يتم تكوين MCPs", + "dialog.lsp.empty": "تم الكشف تلقائيًا عن LSPs من أنواع الملفات", + "dialog.plugins.empty": "الإضافات المكونة في opencode.json", + "mcp.status.connected": "متصل", "mcp.status.failed": "فشل", "mcp.status.needs_auth": "يحتاج إلى مصادقة", @@ -242,7 +245,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "تعذر الاتصال بالخادم", "dialog.server.add.checking": "جارٍ التحقق...", - "dialog.server.add.button": "إضافة", + "dialog.server.add.button": "إضافة خادم", "dialog.server.default.title": "الخادم الافتراضي", "dialog.server.default.description": "الاتصال بهذا الخادم عند بدء تشغيل التطبيق بدلاً من بدء خادم محلي. يتطلب إعادة التشغيل.", @@ -251,6 +254,13 @@ export const dict = { "dialog.server.default.clear": "مسح", "dialog.server.action.remove": "إزالة الخادم", + "dialog.server.menu.edit": "تعديل", + "dialog.server.menu.default": "تعيين كافتراضي", + "dialog.server.menu.defaultRemove": "إزالة الافتراضي", + "dialog.server.menu.delete": "حذف", + "dialog.server.current": "الخادم الحالي", + "dialog.server.status.default": "افتراضي", + "dialog.project.edit.title": "تحرير المشروع", "dialog.project.edit.name": "الاسم", "dialog.project.edit.icon": "أيقونة", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index d5854d896..863e7905e 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -205,6 +205,9 @@ export const dict = { "dialog.mcp.description": "{{enabled}} af {{total}} aktiveret", "dialog.mcp.empty": "Ingen MCP'er konfigureret", + "dialog.lsp.empty": "LSP'er registreret automatisk fra filtyper", + "dialog.plugins.empty": "Plugins konfigureret i opencode.json", + "mcp.status.connected": "forbundet", "mcp.status.failed": "mislykkedes", "mcp.status.needs_auth": "kræver godkendelse", @@ -224,7 +227,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "Kunne ikke forbinde til server", "dialog.server.add.checking": "Tjekker...", - "dialog.server.add.button": "Tilføj", + "dialog.server.add.button": "Tilføj server", "dialog.server.default.title": "Standardserver", "dialog.server.default.description": "Forbind til denne server ved start af app i stedet for at starte en lokal server. Kræver genstart.", @@ -233,6 +236,13 @@ export const dict = { "dialog.server.default.clear": "Ryd", "dialog.server.action.remove": "Fjern server", + "dialog.server.menu.edit": "Rediger", + "dialog.server.menu.default": "Sæt som standard", + "dialog.server.menu.defaultRemove": "Fjern som standard", + "dialog.server.menu.delete": "Slet", + "dialog.server.current": "Nuværende server", + "dialog.server.status.default": "Standard", + "dialog.project.edit.title": "Rediger projekt", "dialog.project.edit.name": "Navn", "dialog.project.edit.icon": "Ikon", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 193257cb7..ca926703f 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -210,6 +210,9 @@ export const dict = { "dialog.mcp.description": "{{enabled}} von {{total}} aktiviert", "dialog.mcp.empty": "Keine MCPs konfiguriert", + "dialog.lsp.empty": "LSPs automatisch nach Dateityp erkannt", + "dialog.plugins.empty": "In opencode.json konfigurierte Plugins", + "mcp.status.connected": "verbunden", "mcp.status.failed": "fehlgeschlagen", "mcp.status.needs_auth": "benötigt Authentifizierung", @@ -229,7 +232,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "Verbindung zum Server fehlgeschlagen", "dialog.server.add.checking": "Prüfen...", - "dialog.server.add.button": "Hinzufügen", + "dialog.server.add.button": "Server hinzufügen", "dialog.server.default.title": "Standardserver", "dialog.server.default.description": "Beim App-Start mit diesem Server verbinden, anstatt einen lokalen Server zu starten. Erfordert Neustart.", @@ -238,6 +241,13 @@ export const dict = { "dialog.server.default.clear": "Löschen", "dialog.server.action.remove": "Server entfernen", + "dialog.server.menu.edit": "Bearbeiten", + "dialog.server.menu.default": "Als Standard festlegen", + "dialog.server.menu.defaultRemove": "Standard entfernen", + "dialog.server.menu.delete": "Löschen", + "dialog.server.current": "Aktueller Server", + "dialog.server.status.default": "Standard", + "dialog.project.edit.title": "Projekt bearbeiten", "dialog.project.edit.name": "Name", "dialog.project.edit.icon": "Icon", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 024703a80..5dd1ac5f3 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -223,6 +223,9 @@ export const dict = { "dialog.mcp.description": "{{enabled}} of {{total}} enabled", "dialog.mcp.empty": "No MCPs configured", + "dialog.lsp.empty": "LSPs auto-detected from file types", + "dialog.plugins.empty": "Plugins configured in opencode.json", + "mcp.status.connected": "connected", "mcp.status.failed": "failed", "mcp.status.needs_auth": "needs auth", @@ -242,7 +245,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "Could not connect to server", "dialog.server.add.checking": "Checking...", - "dialog.server.add.button": "Add", + "dialog.server.add.button": "Add server", "dialog.server.default.title": "Default server", "dialog.server.default.description": "Connect to this server on app launch instead of starting a local server. Requires restart.", @@ -251,6 +254,13 @@ export const dict = { "dialog.server.default.clear": "Clear", "dialog.server.action.remove": "Remove server", + "dialog.server.menu.edit": "Edit", + "dialog.server.menu.default": "Set as default", + "dialog.server.menu.defaultRemove": "Remove default", + "dialog.server.menu.delete": "Delete", + "dialog.server.current": "Current Server", + "dialog.server.status.default": "Default", + "dialog.project.edit.title": "Edit project", "dialog.project.edit.name": "Name", "dialog.project.edit.icon": "Icon", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index b31fcfbcb..8eaa30daf 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -205,6 +205,9 @@ export const dict = { "dialog.mcp.description": "{{enabled}} de {{total}} habilitados", "dialog.mcp.empty": "No hay MCPs configurados", + "dialog.lsp.empty": "LSPs detectados automáticamente por tipo de archivo", + "dialog.plugins.empty": "Plugins configurados en opencode.json", + "mcp.status.connected": "conectado", "mcp.status.failed": "fallido", "mcp.status.needs_auth": "necesita auth", @@ -224,7 +227,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "No se pudo conectar al servidor", "dialog.server.add.checking": "Comprobando...", - "dialog.server.add.button": "Añadir", + "dialog.server.add.button": "Añadir servidor", "dialog.server.default.title": "Servidor predeterminado", "dialog.server.default.description": "Conectar a este servidor al iniciar la app en lugar de iniciar un servidor local. Requiere reinicio.", @@ -233,6 +236,13 @@ export const dict = { "dialog.server.default.clear": "Limpiar", "dialog.server.action.remove": "Eliminar servidor", + "dialog.server.menu.edit": "Editar", + "dialog.server.menu.default": "Establecer como predeterminado", + "dialog.server.menu.defaultRemove": "Quitar predeterminado", + "dialog.server.menu.delete": "Eliminar", + "dialog.server.current": "Servidor actual", + "dialog.server.status.default": "Predeterminado", + "dialog.project.edit.title": "Editar proyecto", "dialog.project.edit.name": "Nombre", "dialog.project.edit.icon": "Icono", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 3b350dcfb..16aba386b 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -205,6 +205,9 @@ export const dict = { "dialog.mcp.description": "{{enabled}} sur {{total}} activés", "dialog.mcp.empty": "Aucun MCP configuré", + "dialog.lsp.empty": "LSPs détectés automatiquement par type de fichier", + "dialog.plugins.empty": "Plugins configurés dans opencode.json", + "mcp.status.connected": "connecté", "mcp.status.failed": "échoué", "mcp.status.needs_auth": "nécessite auth", @@ -224,7 +227,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "Impossible de se connecter au serveur", "dialog.server.add.checking": "Vérification...", - "dialog.server.add.button": "Ajouter", + "dialog.server.add.button": "Ajouter un serveur", "dialog.server.default.title": "Serveur par défaut", "dialog.server.default.description": "Se connecter à ce serveur au lancement de l'application au lieu de démarrer un serveur local. Nécessite un redémarrage.", @@ -233,6 +236,13 @@ export const dict = { "dialog.server.default.clear": "Effacer", "dialog.server.action.remove": "Supprimer le serveur", + "dialog.server.menu.edit": "Modifier", + "dialog.server.menu.default": "Définir par défaut", + "dialog.server.menu.defaultRemove": "Supprimer par défaut", + "dialog.server.menu.delete": "Supprimer", + "dialog.server.current": "Serveur actuel", + "dialog.server.status.default": "Défaut", + "dialog.project.edit.title": "Modifier le projet", "dialog.project.edit.name": "Nom", "dialog.project.edit.icon": "Icône", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 45b87e691..d33d5c7a7 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -204,6 +204,9 @@ export const dict = { "dialog.mcp.description": "{{total}}個中{{enabled}}個が有効", "dialog.mcp.empty": "MCPが設定されていません", + "dialog.lsp.empty": "ファイルタイプから自動検出されたLSP", + "dialog.plugins.empty": "opencode.jsonで設定されたプラグイン", + "mcp.status.connected": "接続済み", "mcp.status.failed": "失敗", "mcp.status.needs_auth": "認証が必要", @@ -223,7 +226,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "サーバーに接続できませんでした", "dialog.server.add.checking": "確認中...", - "dialog.server.add.button": "追加", + "dialog.server.add.button": "サーバーを追加", "dialog.server.default.title": "デフォルトサーバー", "dialog.server.default.description": "ローカルサーバーを起動する代わりに、アプリ起動時にこのサーバーに接続します。再起動が必要です。", @@ -232,6 +235,13 @@ export const dict = { "dialog.server.default.clear": "クリア", "dialog.server.action.remove": "サーバーを削除", + "dialog.server.menu.edit": "編集", + "dialog.server.menu.default": "デフォルトに設定", + "dialog.server.menu.defaultRemove": "デフォルト設定を解除", + "dialog.server.menu.delete": "削除", + "dialog.server.current": "現在のサーバー", + "dialog.server.status.default": "デフォルト", + "dialog.project.edit.title": "プロジェクトを編集", "dialog.project.edit.name": "名前", "dialog.project.edit.icon": "アイコン", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index cfafb7b37..73d0f9687 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -208,6 +208,9 @@ export const dict = { "dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨", "dialog.mcp.empty": "구성된 MCP 없음", + "dialog.lsp.empty": "파일 유형에서 자동 감지된 LSP", + "dialog.plugins.empty": "opencode.json에 구성된 플러그인", + "mcp.status.connected": "연결됨", "mcp.status.failed": "실패", "mcp.status.needs_auth": "인증 필요", @@ -227,7 +230,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "서버에 연결할 수 없습니다", "dialog.server.add.checking": "확인 중...", - "dialog.server.add.button": "추가", + "dialog.server.add.button": "서버 추가", "dialog.server.default.title": "기본 서버", "dialog.server.default.description": "로컬 서버를 시작하는 대신 앱 실행 시 이 서버에 연결합니다. 다시 시작해야 합니다.", @@ -236,6 +239,13 @@ export const dict = { "dialog.server.default.clear": "지우기", "dialog.server.action.remove": "서버 제거", + "dialog.server.menu.edit": "편집", + "dialog.server.menu.default": "기본값으로 설정", + "dialog.server.menu.defaultRemove": "기본값 제거", + "dialog.server.menu.delete": "삭제", + "dialog.server.current": "현재 서버", + "dialog.server.status.default": "기본값", + "dialog.project.edit.title": "프로젝트 편집", "dialog.project.edit.name": "이름", "dialog.project.edit.icon": "아이콘", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0497687aa..133f60aed 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -226,6 +226,9 @@ export const dict = { "dialog.mcp.description": "{{enabled}} av {{total}} aktivert", "dialog.mcp.empty": "Ingen MCP-er konfigurert", + "dialog.lsp.empty": "LSP-er automatisk oppdaget fra filtyper", + "dialog.plugins.empty": "Plugins konfigurert i opencode.json", + "mcp.status.connected": "tilkoblet", "mcp.status.failed": "mislyktes", "mcp.status.needs_auth": "trenger autentisering", @@ -245,7 +248,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "Kunne ikke koble til server", "dialog.server.add.checking": "Sjekker...", - "dialog.server.add.button": "Legg til", + "dialog.server.add.button": "Legg til server", "dialog.server.default.title": "Standardserver", "dialog.server.default.description": "Koble til denne serveren ved oppstart i stedet for å starte en lokal server. Krever omstart.", @@ -254,6 +257,13 @@ export const dict = { "dialog.server.default.clear": "Tøm", "dialog.server.action.remove": "Fjern server", + "dialog.server.menu.edit": "Rediger", + "dialog.server.menu.default": "Sett som standard", + "dialog.server.menu.defaultRemove": "Fjern standard", + "dialog.server.menu.delete": "Slett", + "dialog.server.current": "Gjeldende server", + "dialog.server.status.default": "Standard", + "dialog.project.edit.title": "Rediger prosjekt", "dialog.project.edit.name": "Navn", "dialog.project.edit.icon": "Ikon", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 907d6dac8..46b783082 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -223,6 +223,9 @@ export const dict = { "dialog.mcp.description": "{{enabled}} z {{total}} włączone", "dialog.mcp.empty": "Brak skonfigurowanych MCP", + "dialog.lsp.empty": "LSP wykryte automatycznie na podstawie typów plików", + "dialog.plugins.empty": "Wtyczki skonfigurowane w opencode.json", + "mcp.status.connected": "połączono", "mcp.status.failed": "niepowodzenie", "mcp.status.needs_auth": "wymaga autoryzacji", @@ -242,7 +245,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "Nie można połączyć się z serwerem", "dialog.server.add.checking": "Sprawdzanie...", - "dialog.server.add.button": "Dodaj", + "dialog.server.add.button": "Dodaj serwer", "dialog.server.default.title": "Domyślny serwer", "dialog.server.default.description": "Połącz z tym serwerem przy uruchomieniu aplikacji zamiast uruchamiać lokalny serwer. Wymaga restartu.", @@ -251,6 +254,13 @@ export const dict = { "dialog.server.default.clear": "Wyczyść", "dialog.server.action.remove": "Usuń serwer", + "dialog.server.menu.edit": "Edytuj", + "dialog.server.menu.default": "Ustaw jako domyślny", + "dialog.server.menu.defaultRemove": "Usuń domyślny", + "dialog.server.menu.delete": "Usuń", + "dialog.server.current": "Obecny serwer", + "dialog.server.status.default": "Domyślny", + "dialog.project.edit.title": "Edytuj projekt", "dialog.project.edit.name": "Nazwa", "dialog.project.edit.icon": "Ikona", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index a65791d72..602ff2082 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -223,6 +223,9 @@ export const dict = { "dialog.mcp.description": "{{enabled}} из {{total}} включено", "dialog.mcp.empty": "MCP не настроены", + "dialog.lsp.empty": "LSP автоматически обнаружены по типам файлов", + "dialog.plugins.empty": "Плагины настроены в opencode.json", + "mcp.status.connected": "подключено", "mcp.status.failed": "ошибка", "mcp.status.needs_auth": "требуется авторизация", @@ -242,7 +245,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "Не удалось подключиться к серверу", "dialog.server.add.checking": "Проверка...", - "dialog.server.add.button": "Добавить", + "dialog.server.add.button": "Добавить сервер", "dialog.server.default.title": "Сервер по умолчанию", "dialog.server.default.description": "Подключаться к этому серверу при запуске приложения вместо запуска локального сервера. Требуется перезапуск.", @@ -251,6 +254,13 @@ export const dict = { "dialog.server.default.clear": "Очистить", "dialog.server.action.remove": "Удалить сервер", + "dialog.server.menu.edit": "Редактировать", + "dialog.server.menu.default": "Сделать по умолчанию", + "dialog.server.menu.defaultRemove": "Удалить по умолчанию", + "dialog.server.menu.delete": "Удалить", + "dialog.server.current": "Текущий сервер", + "dialog.server.status.default": "По умолч.", + "dialog.project.edit.title": "Редактировать проект", "dialog.project.edit.name": "Название", "dialog.project.edit.icon": "Иконка", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 9456a5ca2..4777ca665 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -205,6 +205,9 @@ export const dict = { "dialog.mcp.description": "已启用 {{enabled}} / {{total}}", "dialog.mcp.empty": "未配置 MCPs", + "dialog.lsp.empty": "已从文件类型自动检测到 LSPs", + "dialog.plugins.empty": "在 opencode.json 中配置的插件", + "mcp.status.connected": "已连接", "mcp.status.failed": "失败", "mcp.status.needs_auth": "需要授权", @@ -224,7 +227,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "无法连接到服务器", "dialog.server.add.checking": "检查中...", - "dialog.server.add.button": "添加", + "dialog.server.add.button": "添加服务器", "dialog.server.default.title": "默认服务器", "dialog.server.default.description": "应用启动时连接此服务器,而不是启动本地服务器。需要重启。", "dialog.server.default.none": "未选择服务器", @@ -232,6 +235,13 @@ export const dict = { "dialog.server.default.clear": "清除", "dialog.server.action.remove": "移除服务器", + "dialog.server.menu.edit": "编辑", + "dialog.server.menu.default": "设为默认", + "dialog.server.menu.defaultRemove": "取消默认", + "dialog.server.menu.delete": "删除", + "dialog.server.current": "当前服务器", + "dialog.server.status.default": "默认", + "dialog.project.edit.title": "编辑项目", "dialog.project.edit.name": "名称", "dialog.project.edit.icon": "图标", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 1c9403010..5c1b2d645 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -207,6 +207,9 @@ export const dict = { "dialog.mcp.description": "已啟用 {{enabled}} / {{total}}", "dialog.mcp.empty": "未設定 MCP", + "dialog.lsp.empty": "已從檔案類型自動偵測到 LSPs", + "dialog.plugins.empty": "在 opencode.json 中設定的外掛程式", + "mcp.status.connected": "已連線", "mcp.status.failed": "失敗", "mcp.status.needs_auth": "需要授權", @@ -226,7 +229,7 @@ export const dict = { "dialog.server.add.placeholder": "http://localhost:4096", "dialog.server.add.error": "無法連線到伺服器", "dialog.server.add.checking": "檢查中...", - "dialog.server.add.button": "新增", + "dialog.server.add.button": "新增伺服器", "dialog.server.default.title": "預設伺服器", "dialog.server.default.description": "應用程式啟動時連線此伺服器,而不是啟動本地伺服器。需要重新啟動。", "dialog.server.default.none": "未選擇伺服器", @@ -234,6 +237,13 @@ export const dict = { "dialog.server.default.clear": "清除", "dialog.server.action.remove": "移除伺服器", + "dialog.server.menu.edit": "編輯", + "dialog.server.menu.default": "設為預設", + "dialog.server.menu.defaultRemove": "取消預設", + "dialog.server.menu.delete": "刪除", + "dialog.server.current": "目前伺服器", + "dialog.server.status.default": "預設", + "dialog.project.edit.title": "編輯專案", "dialog.project.edit.name": "名稱", "dialog.project.edit.icon": "圖示", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index cafd7ec42..6296b8325 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -43,7 +43,6 @@ uuid = { version = "1.19.0", features = ["v4"] } tauri-plugin-decorum = "1.1.1" comrak = { version = "0.50", default-features = false } - [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" webkit2gtk = "=2.0.1" diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index fcb1cf060..e086acd93 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -525,4 +525,4 @@ async fn spawn_local_server( break Ok(child); } } -} +}
\ No newline at end of file diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index fe9e3f92e..08faadf49 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -417,4 +417,4 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX. </div> </Show> ) -} +}
\ No newline at end of file diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 95641bb20..b2b8a2262 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -214,6 +214,7 @@ [data-slot="list-item"] { display: flex; + position: relative; width: 100%; padding: 6px 8px 6px 8px; align-items: center; @@ -254,6 +255,20 @@ margin-left: -4px; } + [data-slot="list-item-divider"] { + position: absolute; + bottom: 0; + left: var(--list-divider-inset, 16px); + right: var(--list-divider-inset, 16px); + height: 1px; + background: var(--border-weak-base); + pointer-events: none; + } + + [data-slot="list-item"]:last-child [data-slot="list-item-divider"] { + display: none; + } + &[data-active="true"] { border-radius: var(--radius-md); background: var(--surface-raised-base-hover); @@ -272,6 +287,27 @@ outline: none; } } + + [data-slot="list-item-add"] { + display: flex; + position: relative; + width: 100%; + padding: 6px 8px 6px 8px; + align-items: center; + color: var(--text-strong); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + [data-component="input"] { + width: 100%; + } + } } } } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 6a7f3a029..5f585f90c 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -21,6 +21,16 @@ export interface ListSearchProps { action?: JSX.Element } +export interface ListAddProps { + class?: string + render: () => JSX.Element +} + +export interface ListAddProps { + class?: string + render: () => JSX.Element +} + export interface ListProps<T> extends FilteredListProps<T> { class?: string children: (item: T) => JSX.Element @@ -32,6 +42,8 @@ export interface ListProps<T> extends FilteredListProps<T> { filter?: string search?: ListSearchProps | boolean itemWrapper?: (item: T, node: JSX.Element) => JSX.Element + divider?: boolean + add?: ListAddProps } export interface ListRef { @@ -70,6 +82,8 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) const searchProps = () => (typeof props.search === "object" ? props.search : {}) const searchAction = () => searchProps().action + const addProps = () => props.add + const showAdd = () => !!addProps() const moved = (event: MouseEvent) => event.movementX !== 0 || event.movementY !== 0 @@ -159,6 +173,16 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) setScrollRef, }) + const renderAdd = () => { + const add = addProps() + if (!add) return null + return ( + <div data-slot="list-item-add" classList={{ [add.class ?? ""]: !!add.class }}> + {add.render()} + </div> + ) + } + function GroupHeader(groupProps: { category: string }): JSX.Element { const [stuck, setStuck] = createSignal(false) const [header, setHeader] = createSignal<HTMLDivElement | undefined>(undefined) @@ -243,7 +267,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) </Show> <div ref={setScrollRef} data-slot="list-scroll"> <Show - when={flat().length > 0} + when={flat().length > 0 || showAdd()} fallback={ <div data-slot="list-empty-state"> <div data-slot="list-message">{emptyMessage()}</div> @@ -251,55 +275,67 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void }) } > <For each={grouped.latest}> - {(group) => ( - <div data-slot="list-group"> - <Show when={group.category}> - <GroupHeader category={group.category} /> - </Show> - <div data-slot="list-items"> - <For each={group.items}> - {(item, i) => { - const node = ( - <button - data-slot="list-item" - data-key={props.key(item)} - data-active={props.key(item) === active()} - data-selected={item === props.current} - onClick={() => handleSelect(item, i())} - type="button" - onMouseMove={(event) => { - if (!moved(event)) return - setStore("mouseActive", true) - setActive(props.key(item)) - }} - onMouseLeave={() => { - if (!store.mouseActive) return - setActive(null) - }} - > - {props.children(item)} - <Show when={item === props.current}> - <span data-slot="list-item-selected-icon"> - <Icon name="check-small" /> - </span> - </Show> - <Show when={props.activeIcon}> - {(icon) => ( - <span data-slot="list-item-active-icon"> - <Icon name={icon()} /> + {(group, groupIndex) => { + const isLastGroup = () => groupIndex() === grouped.latest.length - 1 + return ( + <div data-slot="list-group"> + <Show when={group.category}> + <GroupHeader category={group.category} /> + </Show> + <div data-slot="list-items"> + <For each={group.items}> + {(item, i) => { + const node = ( + <button + data-slot="list-item" + data-key={props.key(item)} + data-active={props.key(item) === active()} + data-selected={item === props.current} + onClick={() => handleSelect(item, i())} + type="button" + onMouseMove={(event) => { + if (!moved(event)) return + setStore("mouseActive", true) + setActive(props.key(item)) + }} + onMouseLeave={() => { + if (!store.mouseActive) return + setActive(null) + }} + > + {props.children(item)} + <Show when={item === props.current}> + <span data-slot="list-item-selected-icon"> + <Icon name="check-small" /> </span> + </Show> + <Show when={props.activeIcon}> + {(icon) => ( + <span data-slot="list-item-active-icon"> + <Icon name={icon()} /> + </span> + )} + </Show> + {props.divider && (i() !== group.items.length - 1 || (showAdd() && isLastGroup())) && ( + <span data-slot="list-item-divider" /> )} - </Show> - </button> - ) - if (props.itemWrapper) return props.itemWrapper(item, node) - return node - }} - </For> + </button> + ) + if (props.itemWrapper) return props.itemWrapper(item, node) + return node + }} + </For> + <Show when={showAdd() && isLastGroup()}>{renderAdd()}</Show> + </div> </div> - </div> - )} + ) + }} </For> + <Show when={grouped.latest.length === 0 && showAdd()}> + <div data-slot="list-group"> + <div data-slot="list-items">{renderAdd()}</div> + </div> + </Show> </Show> </div> </div> |
