mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(plugin): update plugins from the TUI dialog (#46699)
This commit is contained in:
@@ -25,9 +25,12 @@ export default Runtime.handler(
|
||||
selected,
|
||||
(item) =>
|
||||
(item.runtime === "Server"
|
||||
? Effect.promise(() => result.client.plugin.update({ location: result.location, target: item.target }))
|
||||
? Effect.promise(() => result.client.plugin.update({ location: result.location, targets: [item.target] }))
|
||||
: npm.update(item.target, { subpaths: ["tui"] }).pipe(Effect.asVoid)
|
||||
).pipe(Effect.exit, Effect.map((result) => ({ item, result }))),
|
||||
).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((result) => ({ item, result })),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
for (const item of updated) {
|
||||
|
||||
@@ -97,7 +97,7 @@ export type PluginCheckOperation<E = never> = (input?: PluginCheckInput) => Effe
|
||||
|
||||
export type PluginUpdateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly target: string
|
||||
readonly targets: ReadonlyArray<string>
|
||||
}
|
||||
export type PluginUpdateOutput = void
|
||||
export type PluginUpdateOperation<E = never> = (input: PluginUpdateInput) => Effect.Effect<PluginUpdateOutput, E>
|
||||
|
||||
@@ -335,7 +335,7 @@ const EndpointPluginCheck = (raw: RawClient["server.plugin"]) => (input?: Plugin
|
||||
|
||||
const EndpointPluginUpdate = (raw: RawClient["server.plugin"]) => (input: PluginUpdateInput) =>
|
||||
preserveEffect<PluginUpdateOutput>()(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { target: input["target"] } }).pipe(
|
||||
raw["plugin.update"]({ query: { location: input["location"] }, payload: { targets: input["targets"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -489,7 +489,7 @@ export function make(options: ClientOptions) {
|
||||
method: "POST",
|
||||
path: `/api/plugin/update`,
|
||||
query: { location: input["location"] },
|
||||
body: { target: input["target"] },
|
||||
body: { targets: input["targets"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 503, 401],
|
||||
empty: true,
|
||||
|
||||
@@ -12,7 +12,7 @@ export type PermissionEffect = "allow" | "deny" | "ask"
|
||||
|
||||
export type PluginSource =
|
||||
| { type: "builtin" }
|
||||
| { type: "package"; target: string; version?: string; outdated?: true }
|
||||
| { type: "package"; target: string; version?: string; outdated?: true; updating?: true }
|
||||
| { type: "local"; path: string }
|
||||
| { type: "sdk" }
|
||||
|
||||
@@ -2601,7 +2601,7 @@ export type PluginUpdateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly target: { readonly target: string }["target"]
|
||||
readonly targets: { readonly targets: ReadonlyArray<string> }["targets"]
|
||||
}
|
||||
|
||||
export type PluginUpdateOutput = void
|
||||
|
||||
@@ -106,6 +106,7 @@ export const layer = Layer.effectDiscard(
|
||||
yield* Effect.addFinalizer(registry.close)
|
||||
let packages = new Set<string>()
|
||||
let outdated = new Set<string>()
|
||||
const updating = new Set<string>()
|
||||
let generation = 0
|
||||
let observed = 0
|
||||
|
||||
@@ -128,7 +129,13 @@ export const layer = Layer.effectDiscard(
|
||||
// Activate everything available locally before waiting on missing package installs.
|
||||
const immediate = yield* resolve(pre, post, operations, false)
|
||||
const source = (source: Plugin.Source) =>
|
||||
source.type === "package" && outdated.has(source.target) ? { ...source, outdated: true as const } : source
|
||||
source.type === "package"
|
||||
? {
|
||||
...source,
|
||||
...(outdated.has(source.target) ? { outdated: true as const } : {}),
|
||||
...(updating.has(source.target) ? { updating: true as const } : {}),
|
||||
}
|
||||
: source
|
||||
const apply = (resolved: typeof immediate) =>
|
||||
registry.activate(
|
||||
resolved.plugins.map((plugin) => (plugin.source ? { ...plugin, source: source(plugin.source) } : plugin)),
|
||||
@@ -163,7 +170,10 @@ export const layer = Layer.effectDiscard(
|
||||
updates.changes().pipe(
|
||||
Stream.filter((update) => packages.has(update.target)),
|
||||
Stream.tap((update) =>
|
||||
Effect.sync(() => (update.outdated ? outdated.add(update.target) : outdated.delete(update.target))),
|
||||
Effect.sync(() => {
|
||||
update.outdated ? outdated.add(update.target) : outdated.delete(update.target)
|
||||
update.updating ? updating.add(update.target) : updating.delete(update.target)
|
||||
}),
|
||||
),
|
||||
Stream.map(() => undefined),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export * as PluginUpdate from "./update.js"
|
||||
|
||||
import { Clock, Context, Effect, Layer, Option, PubSub, Stream } from "effect"
|
||||
import { Clock, Context, Effect, Exit, Layer, Option, PubSub, Stream } from "effect"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { EffectFlock } from "@opencode-ai/util/effect-flock"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -8,10 +8,16 @@ import { KeyedMutex } from "../effect/keyed-mutex.js"
|
||||
|
||||
const interval = 24 * 60 * 60 * 1_000
|
||||
|
||||
interface Change {
|
||||
readonly target: string
|
||||
readonly outdated: boolean
|
||||
readonly updating: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly check: (target: string, options?: { readonly refresh?: boolean }) => Effect.Effect<boolean>
|
||||
readonly update: (target: string) => Effect.Effect<void, Npm.InstallFailedError | EffectFlock.LockError>
|
||||
readonly changes: () => Stream.Stream<{ readonly target: string; readonly outdated: boolean }>
|
||||
readonly changes: () => Stream.Stream<Change>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/PluginUpdate") {}
|
||||
@@ -22,7 +28,7 @@ const layer = Layer.effect(
|
||||
const npm = yield* Npm.Service
|
||||
const locks = KeyedMutex.makeUnsafe<string>()
|
||||
const status = new Map<string, { readonly outdated: boolean; readonly checkedAt: number }>()
|
||||
const changes = yield* PubSub.unbounded<{ readonly target: string; readonly outdated: boolean }>()
|
||||
const changes = yield* PubSub.unbounded<Change>()
|
||||
|
||||
return Service.of({
|
||||
check: (target, options) =>
|
||||
@@ -37,15 +43,28 @@ const layer = Layer.effect(
|
||||
)
|
||||
const value = Option.getOrElse(outdated, () => current?.outdated ?? false)
|
||||
status.set(target, { outdated: value, checkedAt })
|
||||
if ((current?.outdated ?? false) !== value) yield* PubSub.publish(changes, { target, outdated: value })
|
||||
if ((current?.outdated ?? false) !== value)
|
||||
yield* PubSub.publish(changes, { target, outdated: value, updating: false })
|
||||
return value
|
||||
}),
|
||||
),
|
||||
// Serialize per target so a concurrent update cannot clear the in-progress flag of another still running.
|
||||
update: (target) =>
|
||||
npm.update(target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => status.delete(target))),
|
||||
Effect.tap(() => PubSub.publish(changes, { target, outdated: false })),
|
||||
Effect.asVoid,
|
||||
locks.withLock(target)(
|
||||
Effect.gen(function* () {
|
||||
const outdated = status.get(target)?.outdated ?? false
|
||||
yield* PubSub.publish(changes, { target, outdated, updating: true })
|
||||
yield* npm.update(target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => status.delete(target))),
|
||||
Effect.onExit((exit) =>
|
||||
PubSub.publish(changes, {
|
||||
target,
|
||||
outdated: Exit.isSuccess(exit) ? false : outdated,
|
||||
updating: false,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
changes: () => Stream.fromPubSub(changes),
|
||||
})
|
||||
|
||||
@@ -701,19 +701,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Update one package plugin and notify active locations to reload it.",
|
||||
"summary": "Update plugin",
|
||||
"description": "Update package plugins concurrently and notify active locations to reload them. Responds once every update has finished; fails when any update fails.",
|
||||
"summary": "Update plugins",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string"
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["target"],
|
||||
"required": ["targets"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -16844,6 +16847,10 @@
|
||||
"outdated": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
},
|
||||
"updating": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
}
|
||||
},
|
||||
"required": ["type", "target"],
|
||||
|
||||
@@ -39,7 +39,7 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
|
||||
.add(
|
||||
HttpApiEndpoint.post("plugin.update", "/api/plugin/update", {
|
||||
query: LocationQuery,
|
||||
payload: Schema.Struct({ target: Schema.String }),
|
||||
payload: Schema.Struct({ targets: Schema.Array(Schema.String) }),
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [InvalidRequestError, ServiceUnavailableError],
|
||||
})
|
||||
@@ -47,8 +47,9 @@ export const PluginGroup = HttpApiGroup.make("server.plugin")
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.plugin.update",
|
||||
summary: "Update plugin",
|
||||
description: "Update one package plugin and notify active locations to reload it.",
|
||||
summary: "Update plugins",
|
||||
description:
|
||||
"Update package plugins concurrently and notify active locations to reload them. Responds once every update has finished; fails when any update fails.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ export const Source = Schema.Union([
|
||||
target: Schema.String,
|
||||
version: Schema.String.pipe(optional),
|
||||
outdated: Schema.Literal(true).pipe(optional),
|
||||
updating: Schema.Literal(true).pipe(optional),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("local"), path: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("sdk") }),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Plugin } from "@opencode-ai/core/plugin"
|
||||
import { PluginUpdate } from "@opencode-ai/core/plugin/update"
|
||||
import { InvalidRequestError, ServiceUnavailableError } from "@opencode-ai/protocol/errors"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
@@ -45,6 +45,7 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
|
||||
target: plugin.source.target,
|
||||
...(plugin.source.version ? { version: plugin.source.version } : {}),
|
||||
...(outdated.get(plugin.source.target) ? { outdated: true as const } : {}),
|
||||
...(plugin.source.updating ? { updating: true as const } : {}),
|
||||
},
|
||||
}
|
||||
}),
|
||||
@@ -56,26 +57,31 @@ export const PluginHandler = HttpApiBuilder.group(Api, "server.plugin", (handler
|
||||
Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
if (
|
||||
!(yield* plugins.list()).some(
|
||||
(plugin) => plugin.source.type === "package" && plugin.source.target === ctx.payload.target,
|
||||
)
|
||||
const inventory = new Set(
|
||||
(yield* plugins.list()).flatMap((plugin) => (plugin.source.type === "package" ? [plugin.source.target] : [])),
|
||||
)
|
||||
const unknown = ctx.payload.targets.filter((target) => !inventory.has(target))
|
||||
if (unknown.length)
|
||||
return yield* new InvalidRequestError({
|
||||
message: `Plugin package is not in the current server inventory: ${ctx.payload.target}`,
|
||||
field: "target",
|
||||
message: `Plugin packages are not in the current server inventory: ${unknown.join(", ")}`,
|
||||
field: "targets",
|
||||
})
|
||||
const updates = yield* PluginUpdate.Service
|
||||
yield* updates.update(ctx.payload.target).pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.fail(
|
||||
new ServiceUnavailableError({
|
||||
message: `Failed to update plugin package ${ctx.payload.target}: ${Cause.pretty(cause)}`,
|
||||
service: "plugin",
|
||||
}),
|
||||
// Let every update run to completion instead of interrupting the rest on the first failure.
|
||||
const failures = yield* Effect.forEach(
|
||||
ctx.payload.targets,
|
||||
(target) =>
|
||||
updates.update(target).pipe(
|
||||
Effect.exit,
|
||||
Effect.map((exit) => (Exit.isFailure(exit) ? [`${target}: ${Cause.pretty(exit.cause)}`] : [])),
|
||||
),
|
||||
),
|
||||
)
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((results) => results.flat()))
|
||||
if (failures.length)
|
||||
return yield* new ServiceUnavailableError({
|
||||
message: `Failed to update plugin packages: ${failures.join("; ")}`,
|
||||
service: "plugin",
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -271,6 +271,7 @@ export const Definitions = {
|
||||
"plugins.toggle": keybind("return", "Toggle plugin"),
|
||||
"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"),
|
||||
|
||||
"terminal.suspend": keybind("ctrl+z", "Suspend terminal"),
|
||||
"terminal.title.toggle": keybind("none", "Toggle terminal title"),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PluginInfo } from "@opencode-ai/client"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { createEffect, createMemo, createResource, createSignal, onMount, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
|
||||
import { DialogErrorDetails } from "../../component/dialog-error-details"
|
||||
import { usePlugin } from "../../plugin/context"
|
||||
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
||||
@@ -30,11 +30,12 @@ export function PluginsDialog(props: {
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const [detail, setDetail] = createSignal<Entry>()
|
||||
const [showInternal, setShowInternal] = createSignal(false)
|
||||
const [server] = createResource(
|
||||
const [server, { refetch }] = createResource(
|
||||
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
|
||||
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
|
||||
)
|
||||
onMount(() => dialog.setSize("medium"))
|
||||
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
|
||||
const entries = createMemo<Entry[]>(() => {
|
||||
const builtins: Entry[] = props.plugins
|
||||
.registered()
|
||||
@@ -127,6 +128,23 @@ export function PluginsDialog(props: {
|
||||
})
|
||||
.finally(() => setLocked(false))
|
||||
}
|
||||
const update = (entry: Entry | undefined) => {
|
||||
if (entry?.runtime !== "server" || entry.plugin.source.type !== "package" || !updatable(entry)) return
|
||||
props.context.client.plugin
|
||||
.update({
|
||||
location: props.context.location ?? props.context.data.location.default(),
|
||||
targets: [entry.plugin.source.target],
|
||||
})
|
||||
.then(() =>
|
||||
props.context.ui.toast.show({ variant: "success", message: `Updated plugin ${label(entry, props.context)}` }),
|
||||
)
|
||||
.catch((cause) => {
|
||||
props.context.ui.toast.show({
|
||||
variant: "error",
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<box>
|
||||
@@ -160,17 +178,20 @@ export function PluginsDialog(props: {
|
||||
return toggle(entry)
|
||||
if (pluginError(entry)) setDetail(entry)
|
||||
}}
|
||||
actions={
|
||||
focusedTui()
|
||||
? [
|
||||
{
|
||||
title: toggleTitle(),
|
||||
command: "plugins.toggle",
|
||||
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
title: toggleTitle(),
|
||||
command: "plugins.toggle",
|
||||
hidden: !focusedTui(),
|
||||
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
|
||||
},
|
||||
{
|
||||
title: "update",
|
||||
command: "dialog.plugins.update",
|
||||
hidden: !updatable(focusedEntry()),
|
||||
onTrigger: (option) => update(entries().find((entry) => entry.key === option.value)),
|
||||
},
|
||||
]}
|
||||
footer={
|
||||
<Show when={pluginError(focusedEntry())}>
|
||||
<text>
|
||||
@@ -227,13 +248,21 @@ function outdated(entry: Entry) {
|
||||
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.outdated === true
|
||||
}
|
||||
|
||||
function updating(entry: Entry) {
|
||||
return entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.updating === true
|
||||
}
|
||||
|
||||
function updatable(entry: Entry | undefined) {
|
||||
return entry !== undefined && outdated(entry) && !updating(entry)
|
||||
}
|
||||
|
||||
function footer(entry: Entry) {
|
||||
const details = [
|
||||
...(status(entry) === "active" ? [] : [status(entry)]),
|
||||
...(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.version
|
||||
? [displayVersion(entry.plugin.source.version)]
|
||||
: []),
|
||||
...(outdated(entry) ? ["update available"] : []),
|
||||
...(updating(entry) ? ["updating"] : outdated(entry) ? ["update available"] : []),
|
||||
]
|
||||
return details.length ? details.join(", ") : undefined
|
||||
}
|
||||
|
||||
@@ -701,19 +701,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Update one package plugin and notify active locations to reload it.",
|
||||
"summary": "Update plugin",
|
||||
"description": "Update package plugins concurrently and notify active locations to reload them. Responds once every update has finished; fails when any update fails.",
|
||||
"summary": "Update plugins",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string"
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["target"],
|
||||
"required": ["targets"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -16844,6 +16847,10 @@
|
||||
"outdated": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
},
|
||||
"updating": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
}
|
||||
},
|
||||
"required": ["type", "target"],
|
||||
|
||||
@@ -701,19 +701,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Update one package plugin and notify active locations to reload it.",
|
||||
"summary": "Update plugin",
|
||||
"description": "Update package plugins concurrently and notify active locations to reload them. Responds once every update has finished; fails when any update fails.",
|
||||
"summary": "Update plugins",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"target": {
|
||||
"type": "string"
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["target"],
|
||||
"required": ["targets"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -16844,6 +16847,10 @@
|
||||
"outdated": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
},
|
||||
"updating": {
|
||||
"type": "boolean",
|
||||
"enum": [true]
|
||||
}
|
||||
},
|
||||
"required": ["type", "target"],
|
||||
|
||||
@@ -344,6 +344,7 @@ The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, an
|
||||
| `plugins.toggle` | `space` | Toggle plugin |
|
||||
| `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 |
|
||||
|
||||
## Terminal And Plugins
|
||||
|
||||
|
||||
Reference in New Issue
Block a user