diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 063205f0c30..7ae28e1e6f9 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -14,6 +14,7 @@ import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" +import { pluginSpec } from "@/utils/plugin-spec" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" import { DialogSelectServer } from "./dialog-select-server" @@ -189,6 +190,7 @@ export function StatusPopover() { const lspItems = createMemo(() => sync.data.lsp ?? []) const lspCount = createMemo(() => lspItems().length) const plugins = createMemo(() => sync.data.config.plugin ?? []) + const pluginItems = createMemo(() => plugins().map((item) => pluginSpec(item)).toSorted((a, b) => a.name.localeCompare(b.name))) const pluginCount = createMemo(() => plugins().length) const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json")) const overallHealthy = createMemo(() => { @@ -404,11 +406,21 @@ export function StatusPopover() { when={plugins().length > 0} fallback={
{pluginEmpty()}
} > - + {(plugin) => ( -
-
- {plugin} +
+
+
+ {plugin.name} + + @{plugin.version} + +
+ + + {plugin.raw} + +
)} diff --git a/packages/app/src/utils/plugin-spec.test.ts b/packages/app/src/utils/plugin-spec.test.ts new file mode 100644 index 00000000000..e434e7bcb9a --- /dev/null +++ b/packages/app/src/utils/plugin-spec.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { pluginSpec } from "./plugin-spec" + +describe("pluginSpec", () => { + test("parses npm package with version", () => { + expect(pluginSpec("oh-my-opencode@2.4.3")).toEqual({ + name: "oh-my-opencode", + version: "2.4.3", + }) + }) + + test("parses scoped npm package with version", () => { + expect(pluginSpec("@scope/plugin@1.0.0")).toEqual({ + name: "@scope/plugin", + version: "1.0.0", + }) + }) + + test("defaults npm package to latest when version is missing", () => { + expect(pluginSpec("@scope/plugin")).toEqual({ + name: "@scope/plugin", + version: "latest", + }) + }) + + test("parses file plugin and keeps raw specifier", () => { + expect(pluginSpec("file:///project/.opencode/plugins/custom-plugin.ts")).toEqual({ + name: "custom-plugin", + raw: "file:///project/.opencode/plugins/custom-plugin.ts", + }) + }) + + test("uses parent directory name when filename is index", () => { + expect(pluginSpec("file:///project/.opencode/plugins/my-plugin/index.js")).toEqual({ + name: "my-plugin", + raw: "file:///project/.opencode/plugins/my-plugin/index.js", + }) + }) +}) diff --git a/packages/app/src/utils/plugin-spec.ts b/packages/app/src/utils/plugin-spec.ts new file mode 100644 index 00000000000..b507cb0468e --- /dev/null +++ b/packages/app/src/utils/plugin-spec.ts @@ -0,0 +1,39 @@ +export type PluginSpec = { + name: string + version?: string + raw?: string +} + +function fileName(value: string) { + try { + const path = decodeURIComponent(new URL(value).pathname) + const parts = path.split("/").filter(Boolean) + const file = parts[parts.length - 1] ?? path + const base = file.replace(/\.[^.]+$/, "") + if (base === "index" && parts.length > 1) { + return parts[parts.length - 2] + } + return base || value + } catch { + return value + } +} + +function pkgName(value: string): PluginSpec { + const at = value.lastIndexOf("@") + if (at <= 0) return { name: value, version: "latest" } + return { + name: value.slice(0, at), + version: value.slice(at + 1), + } +} + +export function pluginSpec(value: string): PluginSpec { + if (value.startsWith("file://")) { + return { + name: fileName(value), + raw: value, + } + } + return pkgName(value) +}