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 { +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): Promise { 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 ( +
+
+
+ +
+
+ ) +} + +function EditRow(props: EditRowProps) { + return ( +
event.stopPropagation()}> +
+
+ +
+
+ ) +} + 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, + 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 = {} 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 })) + } - setStore("adding", true) - setStore("error", "") + const scrollListToBottom = () => { + const scroll = document.querySelector('[data-component="list"] [data-slot="list-scroll"]') + if (!scroll) return + requestAnimationFrame(() => { + scroll.scrollTop = scroll.scrollHeight + }) + } - const result = await checkHealth(value, platform.fetch) - setStore("adding", false) + const handleEditChange = (value: string) => { + if (store.editServer.busy) return + setStore("editServer", { value, error: "" }) + void previewStatus(value, (next) => setStore("editServer", { status: next })) + } - if (!result.healthy) { - setStore("error", language.t("dialog.server.add.error")) + async function handleAdd(value: string) { + if (store.addServer.adding) return + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetAdd() return } - setStore("url", "") - select(value, true) + setStore("addServer", { adding: true, error: "" }) + + const result = await checkHealth(normalized, platform) + setStore("addServer", { adding: false }) + + if (!result.healthy) { + setStore("addServer", { error: language.t("dialog.server.add.error") }) + return + } + + 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 ( - -
+ +
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: () => ( + + ), + } + : undefined + } > - {(i) => ( -
-
-
- {serverDisplayName(i)} - {store.status[i]?.version} + {(i) => { + const [popoverOpen, setPopoverOpen] = createSignal(false) + return ( +
+ handleEditKey(event, i)} + onBlur={() => handleEdit(i, store.editServer.value)} + /> + } + > +
+
+ {serverDisplayName(i)} + + {store.status[i]?.version} + + + + {language.t("dialog.server.status.default")} + + +
+ + +
+ +

{language.t("dialog.server.current")}

+
+ +
e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}> + event.stopPropagation()} + /> + } + class="w-max !min-w-fit !max-w-none" + > +
+ + + + + + + +
+ +
+ +
+
+
- - { - e.stopPropagation() - handleRemove(i) - }} - /> - -
- )} + ) + }} -
-
-

{language.t("dialog.server.add.title")}

-
-
-
-
- { - setStore("url", v) - setStore("error", "") - }} - validationState={store.error ? "invalid" : "valid"} - error={store.error} - /> -
- -
-
+
+
- - -
-
-

{language.t("dialog.server.default.title")}

-

{language.t("dialog.server.default.description")}

-
-
- {language.t("dialog.server.default.none")} - } - > - - - } - > -
- {serverDisplayName(defaultUrl()!)} -
- - -
-
-
) 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 ( - 0}> - -
-
0, - }} - /> - - {language.t("lsp.label.connected", { count: lspStats().connected })} - -
- - - ) -} 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 ( - 0}> - - - ) -} 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) => (
- {/* */} -
- - -
+
-
+ +
)} 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): Promise { + 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(null) + const [store, setStore] = createStore({ + status: {} as Record, + }) + + 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 = {} + 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() + + createEffect(() => { + const result = platform.getDefaultServerUrl?.() + if (result instanceof Promise) { + result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined)) + return + } + if (result) setDefaultServerUrl(normalizeServerUrl(result)) + }) + + return ( + +
+ Status +
+ } + 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} + > +
+ + + + {serverCount() > 0 ? `${serverCount()} ` : ""}Servers + + + {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}MCP + + + {lspCount() > 0 ? `${lspCount()} ` : ""}LSP + + + {pluginCount() > 0 ? `${pluginCount()} ` : ""}Plugins + + + + +
+
+ + {(url) => { + const isActive = () => url === server.url + const isDefault = () => url === defaultServerUrl() + const status = () => store.status[url] + const isBlocked = () => status()?.healthy === false + return ( + + ) + }} + + + +
+
+
+ + +
+
+ 0} + fallback={ +
No MCP servers configured
+ } + > + + {(item) => { + const enabled = () => item.status === "connected" + return ( + + ) + }} + +
+
+
+
+ + +
+
+ 0} + fallback={ +
+ LSPs auto-detected from file types +
+ } + > + + {(item) => ( +
+
+ {item.name || item.id} +
+ )} + + +
+
+ + + +
+
+ 0} + fallback={ +
+ Plugins configured in{" "} + opencode.json +
+ } + > + + {(plugin) => ( +
+
+ {plugin} +
+ )} + + +
+
+ + +
+ + ) +} 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) => JSX.
) -} +} \ 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 extends FilteredListProps { class?: string children: (item: T) => JSX.Element @@ -32,6 +42,8 @@ export interface ListProps extends FilteredListProps { 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(props: ListProps & { 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(props: ListProps & { ref?: (ref: ListRef) => void }) setScrollRef, }) + const renderAdd = () => { + const add = addProps() + if (!add) return null + return ( +
+ {add.render()} +
+ ) + } + function GroupHeader(groupProps: { category: string }): JSX.Element { const [stuck, setStuck] = createSignal(false) const [header, setHeader] = createSignal(undefined) @@ -243,7 +267,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
0} + when={flat().length > 0 || showAdd()} fallback={
{emptyMessage()}
@@ -251,55 +275,67 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) } > - {(group) => ( -
- - - -
- - {(item, i) => { - const node = ( - - ) - if (props.itemWrapper) return props.itemWrapper(item, node) - return node - }} - + + ) + if (props.itemWrapper) return props.itemWrapper(item, node) + return node + }} + + {renderAdd()} +
-
- )} + ) + }} + +
+
{renderAdd()}
+
+