summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorOpeOginni <[email protected]>2026-01-24 19:03:36 +0100
committerGitHub <[email protected]>2026-01-24 12:03:36 -0600
commit67ea21b55a281d6feb00820f1fec94da50165678 (patch)
tree44fc751afe3120de59e5e8adac17b1b40d519bb0 /packages
parentf4cf3f4976b8e8e60b90098bba57c11ffa115a6a (diff)
downloadopencode-67ea21b55a281d6feb00820f1fec94da50165678.tar.gz
opencode-67ea21b55a281d6feb00820f1fec94da50165678.zip
feat(web): implement new server management for web and desktop (#8513)
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/app.tsx2
-rw-r--r--packages/app/src/components/dialog-select-server.tsx531
-rw-r--r--packages/app/src/components/session-lsp-indicator.tsx42
-rw-r--r--packages/app/src/components/session-mcp-indicator.tsx34
-rw-r--r--packages/app/src/components/session/session-header.tsx162
-rw-r--r--packages/app/src/components/status-popover.tsx364
-rw-r--r--packages/app/src/context/server.tsx2
-rw-r--r--packages/app/src/i18n/ar.ts12
-rw-r--r--packages/app/src/i18n/da.ts12
-rw-r--r--packages/app/src/i18n/de.ts12
-rw-r--r--packages/app/src/i18n/en.ts12
-rw-r--r--packages/app/src/i18n/es.ts12
-rw-r--r--packages/app/src/i18n/fr.ts12
-rw-r--r--packages/app/src/i18n/ja.ts12
-rw-r--r--packages/app/src/i18n/ko.ts12
-rw-r--r--packages/app/src/i18n/no.ts12
-rw-r--r--packages/app/src/i18n/pl.ts12
-rw-r--r--packages/app/src/i18n/ru.ts12
-rw-r--r--packages/app/src/i18n/zh.ts12
-rw-r--r--packages/app/src/i18n/zht.ts12
-rw-r--r--packages/desktop/src-tauri/Cargo.toml1
-rw-r--r--packages/desktop/src-tauri/src/lib.rs2
-rw-r--r--packages/desktop/src/index.tsx2
-rw-r--r--packages/ui/src/components/list.css36
-rw-r--r--packages/ui/src/components/list.tsx128
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>