Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions packages/app/component-tests/plugin-host.fixture.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SessionMessageInfo } from "@opencode/client/promise"
import type { PluginInfo, SessionMessageInfo } from "@opencode/client/promise"
import { DialogProvider } from "@opencode/ui/context/dialog"
import { Toast } from "@opencode/ui/toast"
import {
Expand Down Expand Up @@ -44,7 +44,7 @@ import { createClosedTabs } from "../src/runtime/plugin/closed-tabs"
import { PluginSlot } from "../src/runtime/plugin/render"
import { persistedHandle } from "../src/runtime/plugin/stores"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { PluginsSettings } from "../src/runtime/plugin/settings-plugins"
import { ExtensionPlugins } from "../src/runtime/plugin/extension-plugins"
import { SessionPluginPills } from "../src/session/composer/plugin-pills"
import { ui } from "../src/runtime/plugin/ui"

Expand Down Expand Up @@ -226,8 +226,8 @@ export function mountPlugins(input: {
disabled?: readonly string[]
bridge?: Parameters<typeof createInstalled>[0]
stored?: Readonly<Record<string, Json>>
/** Shows the production Settings > Plugins page for status-label contracts. */
settings?: boolean
/** Shows the production Extensions › Plugins rows of the desktop app's own server, which lists these plugins. */
plugins?: readonly PluginInfo[]
/** The client of an Ipc while main provides it, as a desktop window's bridge reports it. Omitted: the web. */
ipc?: (token: Ipc) => IpcClient<IpcSpec> | undefined
}) {
Expand Down Expand Up @@ -310,8 +310,12 @@ export function mountPlugins(input: {

return (
<Show when={host.ready()}>
<Show when={input.settings}>
<PluginsSettings />
<Show when={input.plugins}>
{(plugins) => (
<div data-testid="extension-plugins">
<ExtensionPlugins plugins={plugins()} own />
</div>
)}
</Show>
<PluginSlot at="window.bottom" input={{}} />
</Show>
Expand Down
102 changes: 82 additions & 20 deletions packages/app/component-tests/plugin-host.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Component, Owner } from "solid-js"
import type { PluginInfo } from "@opencode/client/promise"
import type { Context, Contract, DialogHandle, Dialogs, Live, SessionRef, SessionScreen } from "@opencode/plugin/gui"
import type { BuiltinPanelTab } from "@opencode/plugin/gui/builtin"
import type { Bridge, BridgeMessage, EnableState, Installed } from "@opencode/plugin/gui/host"
Expand Down Expand Up @@ -1084,19 +1085,32 @@ story("a contract component reads its provider context and preserves reactive pr
expect(result).toEqual({ context: "provider", mounts: 1, before: "before", after: "after" })
})

story("Settings > Plugins shows where each part of a plugin from the config is turned off", async ({ page }, info) => {
await page.evaluate(async (fixture) => {
story("Extensions › Plugins merges the own server's plugins with the desktop GUIs they run", async ({ page }, info) => {
const calls = await page.evaluateHandle(async (fixture) => {
const { mountPlugins, until } = await import(fixture)
const calls: string[] = []

const installed: Installed[] = [
// A built-in that runs stays hidden; one that failed keeps a row, as nothing else reports it.
{ id: "review", builtin: true, enabled: true },
{ id: "terminal", builtin: true, enabled: true },
{ id: "acme", builtin: false, enabled: true, server: true, tui: true },
// A package with only a GUI part has no other part to tag.
// A package with only a GUI part has no other part to tag, and here the server list does not name it.
{ id: "notes", builtin: false, enabled: false, server: false, tui: false },
// A plugin whose `os` leaves out this system keeps a row that says so, and never loads.
{ id: "windows-only", builtin: false, enabled: true, server: true, unsupported: true },
// A bundle main could not read never reaches the window.
{ id: "garbled", builtin: false, enabled: true, error: "SyntaxError: garbled bundle" },
{ id: "flaky", builtin: false, enabled: true },
]

const listed = (id: string, features: PluginInfo["features"]): PluginInfo => ({
id,
source: { type: "package", target: id },
features,
state: { status: "active" },
})

const bridge: Bridge = {
packaged: false,
call: async () => undefined,
Expand All @@ -1110,33 +1124,81 @@ story("Settings > Plugins shows where each part of a plugin from the config is t
manager: {
initial: Promise.resolve(installed),
list: async () => installed,
enable: async () => {},
disable: async () => {},
reload: async () => {},
enable: async (id) => void calls.push(`enable ${id}`),
disable: async (id) => void calls.push(`disable ${id}`),
reload: async (id) => void calls.push(`reload ${id}`),
},
}

const fails = (message: string) => async () => ({
default: () => {
throw new Error(message)
},
})

const host = mountPlugins({
settings: true,
bridge,
definitions: installed.flatMap((item) =>
item.unsupported ? [] : [{ id: item.id, windowEntry: async () => ({ default: () => {} }) }],
),
plugins: [
listed("acme", { gui: true, server: true, tui: true }),
listed("windows-only", { gui: true, server: true }),
listed("flaky", { gui: true }),
// A plugin without a GUI lists its name only.
listed("lint-rules", { server: true }),
// The server's own built-ins are not listed, as on every server.
{ ...listed("opencode-builtin", { server: true }), source: { type: "builtin" } },
],
definitions: [
{ id: "review", windowEntry: async () => ({ default: () => {} }) },
{ id: "terminal", windowEntry: fails("terminal broke") },
{ id: "acme", windowEntry: async () => ({ default: () => {} }) },
{ id: "notes", windowEntry: async () => ({ default: () => {} }) },
{ id: "flaky", windowEntry: fails("flaky broke") },
],
})

await until(() => !!host.container.textContent?.includes("cli.json"))
await until(() => !!host.container.textContent?.includes("cli.json") && host.status("flaky") === "failed")

return calls
}, fixture)
const settings = page.getByRole("region", { name: "Plugins", exact: true })
await settings.screenshot({ path: info.outputPath("plugin-parts.png") })

const plugins = page.getByTestId("extension-plugins")
await plugins.screenshot({ path: info.outputPath("extension-plugins.png") })
// One row per plugin id, in the server's order, then what only main reports, then failed built-ins.
await expect(plugins.locator(".settings-extension-name")).toHaveText([
"acme",
"windows-only",
"flaky",
"lint-rules",
"notes",
"garbled",
"terminal",
])
// Only plugins from the config tag their other parts, and one note says how to turn those off.
await expect(settings.getByText("Server", { exact: true })).toHaveCount(1)
await expect(settings.getByText("TUI", { exact: true })).toHaveCount(1)
await expect(settings.getByText(/Switches turn off only a plugin's desktop UI/)).toHaveCount(1)
await expect(settings.getByText("Not available on this system.", { exact: true })).toBeVisible()
await expect(plugins.getByText("Server", { exact: true })).toHaveCount(1)
await expect(plugins.getByText("TUI", { exact: true })).toHaveCount(1)
await expect(plugins.getByText(/Switches turn off only a plugin's desktop UI/)).toHaveCount(1)
await expect(plugins.getByText("Not available on this system.", { exact: true })).toBeVisible()
// An error shows its first line; the rest, such as a stack trace, is behind a toggle that a one-line error lacks.
await expect(plugins.getByText("SyntaxError: garbled bundle", { exact: true })).toBeVisible()
await expect(plugins.getByText("Error: flaky broke", { exact: true })).toBeVisible()
await expect(plugins.getByText("Error: terminal broke", { exact: true })).toBeVisible()
await expect(plugins.getByRole("button", { name: "Show details" })).toHaveCount(2)
const flaky = plugins.locator(".settings-extension-row").filter({ hasText: "flaky" })
await flaky.getByRole("button", { name: "Show details" }).press("Enter")
await expect(flaky.locator("pre")).toHaveText(/^Error: flaky broke\s+at /)
await expect(flaky.getByRole("button", { name: "Hide details" })).toHaveAttribute("aria-expanded", "true")
await expect(plugins.getByText("Failed", { exact: true })).toHaveCount(2)
await expect(plugins.getByText("Built-in", { exact: true })).toHaveCount(1)
// A built-in always runs, so it has no switch; the switches carry on and off without a status label.
await expect(settings.getByRole("switch")).toHaveCount(2)
await expect(settings.getByRole("switch", { name: "terminal" })).toHaveCount(0)
await expect(settings.getByText("Active", { exact: true })).toHaveCount(0)
await expect(plugins.getByRole("switch")).toHaveCount(3)
await expect(plugins.getByRole("switch", { name: "terminal" })).toHaveCount(0)
await expect(plugins.getByRole("button", { name: "Reload" })).toHaveCount(4)
await expect(plugins.getByText("Active", { exact: true })).toHaveCount(0)

// The fixture mounts below the story, out of the viewport, so the controls are driven from the keyboard.
await plugins.getByRole("switch", { name: "acme" }).press("Space")
await plugins.getByRole("button", { name: "Reload" }).nth(1).press("Enter")
expect(await calls.jsonValue()).toEqual(["disable acme", "reload flaky"])
})

const invalidSetups = ["promise", "thenable"]
Expand Down
4 changes: 2 additions & 2 deletions packages/app/src/providers/catalog/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { PluginInfo } from "@opencode/client"

function pluginLabel(plugin: PluginInfo) {
export function pluginLabel(plugin: PluginInfo) {
if (plugin.id) return plugin.id

if (plugin.source.type === "package") return plugin.source.target
Expand All @@ -11,5 +11,5 @@ function pluginLabel(plugin: PluginInfo) {
}

export function pluginLabels(plugins: readonly PluginInfo[]) {
return plugins.filter((plugin) => plugin.source.type !== "builtin").map(pluginLabel)
return plugins.flatMap((plugin) => (plugin.source.type === "builtin" ? [] : [pluginLabel(plugin)]))
}
4 changes: 2 additions & 2 deletions packages/app/src/runtime/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,12 +766,12 @@ export const dict = {
"settings.experimental.row.newIcons.description": "Preview the updated icon set",
"settings.experimental.row.newModelPicker.title": "New model picker",
"settings.experimental.row.newModelPicker.description": "Preview the redesigned model picker",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Built-in plugins and plugins from your OpenCode config",
"settings.plugins.reload": "Reload",
"settings.plugins.status.loading": "Loading",
"settings.plugins.status.failed": "Failed",
"settings.plugins.error.notFound": "Plugin not found",
"settings.plugins.error.show": "Show details",
"settings.plugins.error.hide": "Hide details",
"settings.plugins.builtin": "Built-in",
"settings.plugins.reserved": "Not loaded. A built-in plugin already uses this ID.",
"settings.plugins.unsupported": "Not available on this system.",
Expand Down
Loading
Loading