diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fdaefdf..09d468b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- Settings: added a Plugins page to view, add, edit, and remove opencode plugins. Supports `plugin` array entries (npm packages, scoped npm, versioned specs, local paths) with optional options JSON, plus auto-loaded plugin files in `~/.config/opencode/plugins/` and `/.opencode/plugins/`. +- Settings/Plugins: the Plugins page now talks to the npm registry. Sidebar rows show an update badge with the latest available version, group headers show how many updates are available, the kebab menu adds an "Update to latest" action, and the editor surfaces a banner for available updates, missing/invalid versions, missing-package, missing local file, and offline-registry states. Results are cached for one hour with a "Check for updates" button in the sidebar header for forced refresh. + ## [1.11.5] - 2026-05-25 - Chat/Input: pending image attachments now show previews, sent image attachments can be cited from assistant messages, and markdown source mode highlights formatting while you type. diff --git a/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx b/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx new file mode 100644 index 00000000..dcff546e --- /dev/null +++ b/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx @@ -0,0 +1,304 @@ +import React from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { Radio } from '@/components/ui/radio'; +import { toast } from '@/components/ui'; +import { Icon } from '@/components/icon/Icon'; +import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip'; +import { cn } from '@/lib/utils'; +import { useI18n } from '@/lib/i18n'; +import { usePluginsStore, type PluginScope } from '@/stores/usePluginsStore'; + +type TabKey = 'npm' | 'path' | 'file'; + +interface AddPluginDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + defaultScope?: PluginScope; +} + +const FILENAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/; + +function parseOptions(raw: string): { ok: true; value?: Record } | { ok: false } { + const trimmed = raw.trim(); + if (trimmed === '') return { ok: true, value: undefined }; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false }; + } + return { ok: true, value: parsed as Record }; + } catch { + return { ok: false }; + } +} + +export const AddPluginDialog: React.FC = ({ + open, + onOpenChange, + defaultScope = 'user', +}) => { + const { t } = useI18n(); + const createEntry = usePluginsStore((s) => s.createEntry); + const createFile = usePluginsStore((s) => s.createFile); + + const [tab, setTab] = React.useState('npm'); + const [spec, setSpec] = React.useState(''); + const [optionsJson, setOptionsJson] = React.useState(''); + const [fileName, setFileName] = React.useState(''); + const [content, setContent] = React.useState(''); + const [scope, setScope] = React.useState(defaultScope); + const [submitting, setSubmitting] = React.useState(false); + + const resetForm = React.useCallback(() => { + setSpec(''); + setOptionsJson(''); + setFileName(''); + setContent(''); + setScope(defaultScope); + }, [defaultScope]); + + React.useEffect(() => { + if (open) { + setTab('npm'); + resetForm(); + } + }, [open, resetForm]); + + const handleTabChange = (next: TabKey) => { + if (next === tab) return; + setTab(next); + resetForm(); + }; + + const optionsResult = React.useMemo(() => parseOptions(optionsJson), [optionsJson]); + const optionsInvalid = !optionsResult.ok; + const fileNameInvalid = tab === 'file' && fileName.trim() !== '' && !FILENAME_PATTERN.test(fileName.trim()); + const specEmpty = (tab === 'npm' || tab === 'path') && spec.trim() === ''; + const contentEmpty = tab === 'file' && content.trim() === ''; + const fileNameEmpty = tab === 'file' && fileName.trim() === ''; + + const submitDisabled = + submitting || + optionsInvalid || + (tab === 'npm' && specEmpty) || + (tab === 'path' && specEmpty) || + (tab === 'file' && (fileNameEmpty || fileNameInvalid || contentEmpty)); + + const handleSubmit = async () => { + if (submitDisabled) return; + setSubmitting(true); + try { + let result; + if (tab === 'file') { + result = await createFile({ fileName: fileName.trim(), content, scope }); + } else { + result = await createEntry({ + spec: spec.trim(), + options: optionsResult.ok ? optionsResult.value : undefined, + scope, + }); + } + if (result.ok) { + toast.success(result.message || t('settings.plugins.toast.created')); + if (result.reloadFailed) { + toast.warning(t('settings.plugins.toast.reloadFailed')); + } + onOpenChange(false); + } else { + toast.error(result.message || t('settings.plugins.sidebar.toast.deleteFailed')); + } + } finally { + setSubmitting(false); + } + }; + + const tabs = React.useMemo(() => [ + { id: 'npm', label: t('settings.plugins.dialog.add.tab.npm') }, + { id: 'path', label: t('settings.plugins.dialog.add.tab.path') }, + { id: 'file', label: t('settings.plugins.dialog.add.tab.file') }, + ], [t]); + + return ( + { + if (!next && submitting) return; + onOpenChange(next); + }} + > + + + {t('settings.plugins.dialog.add.title')} + + {t('settings.plugins.sidebar.empty.description')} + + + + handleTabChange(id as TabKey)} + layoutMode="fit" + variant="active-pill" + activePillLowercase={false} + className="h-10" + /> + +
+ {(tab === 'npm' || tab === 'path') && ( + <> +
+ + setSpec(e.target.value)} + placeholder={t('settings.plugins.page.field.spec.placeholder')} + aria-invalid={specEmpty ? false : undefined} + disabled={submitting} + /> + {specEmpty && ( +

+ {t('settings.plugins.validation.specRequired')} +

+ )} +
+ +
+ +