feat(tui): check for plugin updates from the plugins dialog (#46884)

This commit is contained in:
Kit Langton
2026-09-02 15:40:40 -04:00
committed by GitHub
Unverified
parent fc051e49ee
commit 2ae235078a
4 changed files with 202 additions and 1 deletions
+1
View File
@@ -272,6 +272,7 @@ export const Definitions = {
"dialog.mcp.toggle": keybind("space", "Toggle MCP server"),
"dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"),
"dialog.plugins.update": keybind("ctrl+u", "Update plugin from plugin dialog"),
"dialog.plugins.check": keybind("ctrl+r", "Check for plugin updates from plugin dialog"),
"terminal.suspend": keybind("ctrl+z", "Suspend terminal"),
"terminal.title.toggle": keybind("none", "Toggle terminal title"),
@@ -27,10 +27,11 @@ export function PluginsDialog(props: {
}) {
const dialog = useDialog()
const [locked, setLocked] = createSignal(false)
const [checking, setChecking] = createSignal(false)
const [focused, setFocused] = createSignal<string>()
const [detail, setDetail] = createSignal<Entry>()
const [showInternal, setShowInternal] = createSignal(false)
const [server, { refetch }] = createResource(
const [server, { refetch, mutate }] = createResource(
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
@@ -145,6 +146,34 @@ export function PluginsDialog(props: {
})
})
}
// The server only re-checks package sources on startup and then caches the
// result for a day, so a merge pushed after launch stays invisible until the
// user asks. The check response carries fresh `outdated` flags for the whole
// inventory; apply it directly instead of waiting for a `plugin.updated`
// event, which only fires when a flag actually changes.
const check = () => {
if (checking()) return
setChecking(true)
props.context.client.plugin
.check({ location: props.context.location ?? props.context.data.location.default() })
.then((result) => {
mutate(result.data)
const count = result.data.filter(
(plugin) => plugin.source.type === "package" && plugin.source.outdated === true,
).length
props.context.ui.toast.show({
variant: count ? "info" : "success",
message: count ? `${count} plugin update${count === 1 ? "" : "s"} available` : "All plugins are up to date",
})
})
.catch((cause) => {
props.context.ui.toast.show({
variant: "error",
message: cause instanceof Error ? cause.message : String(cause),
})
})
.finally(() => setChecking(false))
}
return (
<box>
@@ -191,6 +220,16 @@ export function PluginsDialog(props: {
hidden: !updatable(focusedEntry()),
onTrigger: (option) => update(entries().find((entry) => entry.key === option.value)),
},
{
title: checking() ? "checking" : "check",
command: "dialog.plugins.check",
selection: "none",
hidden: !entries().some(
(entry) => entry.runtime === "server" && entry.plugin.source.type === "package",
),
disabled: checking(),
onTrigger: check,
},
]}
footer={
<Show when={pluginError(focusedEntry())}>
@@ -0,0 +1,160 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { onCleanup } from "solid-js"
import type { PluginInfo } from "@opencode-ai/client"
import type { Context, ToastOptions } from "@opencode-ai/plugin/tui/context"
import { ConfigProvider } from "../../../src/config"
import { Keymap } from "../../../src/context/keymap"
import { ThemeProvider, useThemes } from "../../../src/context/theme"
// The plugin context registers every builtin, and the plugins dialog imports
// the context back, so the context must load first exactly as it does in the app.
import type { usePlugin } from "../../../src/plugin/context"
import "../../../src/plugin/context"
import { PluginsDialog } from "../../../src/feature-plugins/system/plugins"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { emptyThemeSource, tmpdir } from "../../fixture/fixture"
import { createApi, createFetch, json } from "../../fixture/tui-client"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
const target = "git+ssh://git@github.com/example/team-plugins.git"
function packagePlugin(outdated: boolean): PluginInfo {
return {
id: "team.plugins",
source: { type: "package", target, version: "dadba13", ...(outdated ? { outdated: true as const } : {}) },
features: { server: true },
state: { status: "active" },
}
}
async function renderPlugins(root: string, inventory: { list: PluginInfo[]; check: PluginInfo[] }) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
const requests: { path: string; body: unknown }[] = []
const toasts: ToastOptions[] = []
const location = { directory: root, project: { id: "proj_test", directory: root, canonical: root } }
const transport = createFetch(async (url, request) => {
if (url.pathname === "/api/plugin") return json({ location, data: inventory.list })
if (url.pathname === "/api/plugin/check") {
requests.push({ path: url.pathname, body: await request.json() })
return json({ location, data: inventory.check })
}
if (url.pathname === "/api/plugin/update") {
requests.push({ path: url.pathname, body: await request.json() })
return new Response(null, { status: 204 })
}
})
function Harness() {
function Content() {
onCleanup(Keymap.use().mode.push("modal"))
const theme = useThemes().currentTokens()
const context = {
client: createApi(transport.fetch),
data: { location: { default: () => ({ directory: root }) }, on: () => () => {} },
get theme() {
return theme
},
ui: {
toast: { show: (toast: ToastOptions) => toasts.push(toast) },
format: { path: (value: string) => value },
},
} as unknown as Context
const plugins = {
registered: () => [],
list: () => [],
activate: async () => true,
deactivate: async () => true,
} as unknown as ReturnType<typeof usePlugin>
return <PluginsDialog context={context} plugins={plugins} />
}
return (
<TestTuiContexts directory={root} paths={{ home: root, state, worktree: root }}>
<ConfigProvider config={createTuiResolvedConfig()}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<Content />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Plugins"))
return { app, requests, toasts }
}
test("checking for updates refreshes the inventory and reveals the update action", async () => {
await using tmp = await tmpdir()
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] })
try {
const initial = await fixture.app.waitForFrame((frame) => frame.includes("dadba13"))
expect(initial).toContain("check ctrl+r")
expect(initial).not.toContain("update available")
expect(initial).not.toContain("ctrl+u")
fixture.app.mockInput.pressKey("r", { ctrl: true })
const checked = await fixture.app.waitForFrame((frame) => frame.includes("update available"))
expect(checked).toContain("ctrl+u")
expect(fixture.requests).toEqual([{ path: "/api/plugin/check", body: {} }])
expect(fixture.toasts).toEqual([{ variant: "info", message: "1 plugin update available" }])
fixture.app.mockInput.pressKey("u", { ctrl: true })
await fixture.app.waitFor(() => fixture.requests.length === 2)
expect(fixture.requests[1]).toEqual({ path: "/api/plugin/update", body: { targets: [target] } })
} finally {
fixture.app.renderer.destroy()
}
})
test("checking for updates reports an up-to-date inventory", async () => {
await using tmp = await tmpdir()
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(false)] })
try {
await fixture.app.waitForFrame((frame) => frame.includes("dadba13"))
fixture.app.mockInput.pressKey("r", { ctrl: true })
await fixture.app.waitFor(() => fixture.toasts.length === 1)
expect(fixture.toasts).toEqual([{ variant: "success", message: "All plugins are up to date" }])
expect(fixture.requests).toEqual([{ path: "/api/plugin/check", body: {} }])
} finally {
fixture.app.renderer.destroy()
}
})
test("the check action stays hidden without package plugins", async () => {
await using tmp = await tmpdir()
const local: PluginInfo = {
id: "local.plugin",
source: { type: "local", path: path.join(tmp.path, "plugin.ts") },
features: { server: true },
state: { status: "active" },
}
const fixture = await renderPlugins(tmp.path, { list: [local], check: [] })
try {
const frame = await fixture.app.waitForFrame((frame) => frame.includes("local.plugin"))
expect(frame).not.toContain("ctrl+r")
fixture.app.mockInput.pressKey("r", { ctrl: true })
await fixture.app.flush()
expect(fixture.requests).toEqual([])
} finally {
fixture.app.renderer.destroy()
}
})
@@ -345,6 +345,7 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an
| `dialog.mcp.toggle` | `space` | Toggle MCP server |
| `dialog.plugins.install` | `shift+i` | Install plugin from plugin dialog |
| `dialog.plugins.update` | `ctrl+u` | Update plugin from plugin dialog |
| `dialog.plugins.check` | `ctrl+r` | Check for plugin updates |
## Terminal And Plugins