feat: plugin settings (#1375)
* feat(settings): add opencode plugins page Manage opencode `plugin` array entries (npm, scoped npm, versioned, local paths) and auto-loaded plugin files in `~/.config/opencode/plugins/` and `<project>/.opencode/plugins/`. Mirrors MCP CRUD pattern. - Server: `plugins.js` data layer + `plugin-routes.js` REST routes - UI: PluginsSidebar / PluginsPage / AddPluginDialog - Store: usePluginsStore (cache TTL, in-flight dedup, narrow selectors) - i18n: 41 keys across 7 locales Whitelist /api/config/plugins in JSON body-parser so POST/PATCH bodies parse; opencode plugin specs runtime-resolve OPENCODE_CONFIG dir so parallel test files do not cross-pollute module-frozen consts. * feat(settings/plugins): hook npm registry for update + invalid-version detection Plugins page now consults registry.npmjs.org with a 1h server cache. Sidebar rows show an update badge with the latest version, group headers show how many updates are available, the kebab adds an "Update to latest" action that reuses the existing PATCH+restart flow, and the editor surfaces a banner for update-available / missing-version / missing-package / malformed / missing-path / unreadable-path / offline-registry states. A refresh button in the sidebar header forces a cache bypass. - Server: `npm-registry.js` (cache + in-flight dedup + 5s timeout, 404 cached, network failures NOT cached) + `plugin-spec.js` (parser + exact semver detection) + `GET /api/config/plugins/registry?specs=...&refresh=` - Routes accept up to 100 specs/request, dedup by npm package name before fetching, classify each result by kind, never propagate network failure as 500. - Client: `registryInfo` slice + `loadRegistryInfo` (fire-and-forget after loadPlugins, refreshes on mutations) + `updateToLatest(id)`. - UI: `RegistryBadge` per-row + `RegistryBanner` per-entry editor, both use theme tokens (text-only color, no new bg/border tokens) and the shared Icon sprite. Per-spec subscriptions only. - i18n: 24 new keys (incl. split singular/plural for "N update(s) available" because the runtime does not parse ICU plural format). * fix(settings/plugins): keep registry badge visible for long specs Sidebar entry row used `inline-flex` with `truncate` only on the spec text. With long npm specs the badge could be pushed past the row edge and clipped by the parent overflow. Switch to `flex` with spec `flex-1 min-w-0 truncate` and add `shrink-0` to the badge wrapper so the update indicator stays anchored to the right of the row. * fix(settings/plugins): use code-box icon to distinguish from MCP Plugins nav entry used 'plug' which is visually too close to MCP's 'plug-2' icon. Swap to 'code-box' for clearer differentiation in the Settings nav list. * Update packages/ui/src/components/sections/plugins/PluginsPage.tsx Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com> * Update packages/ui/src/stores/usePluginsStore.ts Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com> * fix(settings/plugins): validate registry directory + surface save errors - registry endpoint: return 400 on invalid directory query (was silently falling back to homedir, breaking relative path specs) - save failure toast: prefer result.message over generic 'Reload failed' * fix(settings/plugins): address review follow-ups --------- Signed-off-by: Quat3rnion <81202811+Quat3rnion@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
a25e64099c
commit
2b47d899c6
@@ -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<string, unknown> } | { 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<string, unknown> };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
export const AddPluginDialog: React.FC<AddPluginDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
defaultScope = 'user',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const createEntry = usePluginsStore((s) => s.createEntry);
|
||||
const createFile = usePluginsStore((s) => s.createFile);
|
||||
|
||||
const [tab, setTab] = React.useState<TabKey>('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<PluginScope>(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<SortableTabsStripItem[]>(() => [
|
||||
{ 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 (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next && submitting) return;
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.plugins.dialog.add.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.plugins.sidebar.empty.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<SortableTabsStrip
|
||||
items={tabs}
|
||||
activeId={tab}
|
||||
onSelect={(id) => handleTabChange(id as TabKey)}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
activePillLowercase={false}
|
||||
className="h-10"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{(tab === 'npm' || tab === 'path') && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="plugin-spec" className="typography-ui-label text-foreground">
|
||||
{t('settings.plugins.page.field.spec')}
|
||||
</label>
|
||||
<Input
|
||||
id="plugin-spec"
|
||||
value={spec}
|
||||
onChange={(e) => setSpec(e.target.value)}
|
||||
placeholder={t('settings.plugins.page.field.spec.placeholder')}
|
||||
aria-invalid={specEmpty ? false : undefined}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{specEmpty && (
|
||||
<p className="typography-meta text-muted-foreground">
|
||||
{t('settings.plugins.validation.specRequired')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="plugin-options" className="typography-ui-label text-foreground">
|
||||
{t('settings.plugins.page.field.options')}
|
||||
</label>
|
||||
<Textarea
|
||||
id="plugin-options"
|
||||
value={optionsJson}
|
||||
onChange={(e) => setOptionsJson(e.target.value)}
|
||||
rows={5}
|
||||
className="font-mono"
|
||||
hasError={optionsInvalid}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{optionsInvalid && (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
{t('settings.plugins.page.field.options.invalidJson')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'file' && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="plugin-filename" className="typography-ui-label text-foreground">
|
||||
{t('settings.plugins.page.field.fileName')}
|
||||
</label>
|
||||
<Input
|
||||
id="plugin-filename"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
placeholder="my-plugin.ts"
|
||||
aria-invalid={fileNameInvalid || undefined}
|
||||
disabled={submitting}
|
||||
/>
|
||||
{fileNameInvalid && (
|
||||
<p className="typography-meta text-[var(--status-error)]">
|
||||
{t('settings.plugins.validation.fileName')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="plugin-content" className="typography-ui-label text-foreground">
|
||||
{t('settings.plugins.page.field.content')}
|
||||
</label>
|
||||
<Textarea
|
||||
id="plugin-content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
rows={16}
|
||||
className="font-mono"
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="typography-ui-label text-foreground">
|
||||
{t('settings.plugins.page.field.scope')}
|
||||
</span>
|
||||
<div className="flex items-center gap-4">
|
||||
{(['user', 'project'] as const).map((value) => {
|
||||
const selected = scope === value;
|
||||
const label =
|
||||
value === 'user'
|
||||
? t('settings.plugins.scope.user')
|
||||
: t('settings.plugins.scope.project');
|
||||
return (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setScope(value)}
|
||||
disabled={submitting}
|
||||
className="flex items-center gap-2 py-1 text-left disabled:opacity-50"
|
||||
>
|
||||
<Radio
|
||||
checked={selected}
|
||||
onChange={() => setScope(value)}
|
||||
ariaLabel={label}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'typography-ui-label font-normal',
|
||||
selected ? 'text-foreground' : 'text-foreground/60',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={submitting}
|
||||
>
|
||||
{t('settings.plugins.dialog.add.action.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void handleSubmit();
|
||||
}}
|
||||
disabled={submitDisabled}
|
||||
>
|
||||
{submitting ? (
|
||||
<Icon name="loader-4" className="h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
{t('settings.plugins.dialog.add.action.submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPluginDialog;
|
||||
@@ -0,0 +1,386 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { RegistryBanner } from './RegistryBanner';
|
||||
import {
|
||||
usePluginsStore,
|
||||
type PluginDraft,
|
||||
type PluginEntry,
|
||||
type PluginFile,
|
||||
type PluginScope,
|
||||
} from '@/stores/usePluginsStore';
|
||||
|
||||
interface OptionsParseResult {
|
||||
ok: boolean;
|
||||
value?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function parseOptionsJson(raw: string): OptionsParseResult {
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return { ok: true, value: undefined };
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(trimmed);
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, value: parsed as Record<string, unknown> };
|
||||
} catch {
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyOptions(options: Record<string, unknown> | undefined): string {
|
||||
if (!options || Object.keys(options).length === 0) {
|
||||
return '';
|
||||
}
|
||||
return JSON.stringify(options, null, 2);
|
||||
}
|
||||
|
||||
function buildEntryDraft(entry: PluginEntry): PluginDraft {
|
||||
return {
|
||||
mode: 'entry',
|
||||
scope: entry.scope,
|
||||
spec: entry.spec,
|
||||
optionsJson: stringifyOptions(entry.options),
|
||||
fileName: '',
|
||||
content: '',
|
||||
};
|
||||
}
|
||||
|
||||
function buildFileDraft(file: PluginFile, content: string): PluginDraft {
|
||||
return {
|
||||
mode: 'file',
|
||||
scope: file.scope,
|
||||
spec: '',
|
||||
optionsJson: '',
|
||||
fileName: file.fileName,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
const ScopeBadge: React.FC<{ scope: PluginScope; label: string }> = ({ scope, label }) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'typography-micro font-medium rounded-full px-2 py-0.5',
|
||||
'bg-[var(--surface-elevated)] text-muted-foreground',
|
||||
'border border-[var(--interactive-border)]',
|
||||
)}
|
||||
data-scope={scope}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export const PluginsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedId = usePluginsStore((s) => s.selectedId);
|
||||
const entries = usePluginsStore((s) => s.entries);
|
||||
const files = usePluginsStore((s) => s.files);
|
||||
const draft = usePluginsStore((s) => s.draft);
|
||||
const setDraft = usePluginsStore((s) => s.setDraft);
|
||||
const updateEntry = usePluginsStore((s) => s.updateEntry);
|
||||
const updateFile = usePluginsStore((s) => s.updateFile);
|
||||
const readFile = usePluginsStore((s) => s.readFile);
|
||||
|
||||
const selectedEntry = React.useMemo(
|
||||
() => (selectedId ? entries.find((e) => e.id === selectedId) ?? null : null),
|
||||
[entries, selectedId],
|
||||
);
|
||||
const selectedFile = React.useMemo(
|
||||
() => (selectedId ? files.find((f) => f.id === selectedId) ?? null : null),
|
||||
[files, selectedId],
|
||||
);
|
||||
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
|
||||
const originalFileContentById = React.useRef(new Map<string, string>());
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (selectedEntry) {
|
||||
setDraft(buildEntryDraft(selectedEntry));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedFile) {
|
||||
setIsLoadingFile(true);
|
||||
void (async () => {
|
||||
const result = await readFile(selectedFile.id);
|
||||
if (cancelled) return;
|
||||
setIsLoadingFile(false);
|
||||
const content = result?.content ?? '';
|
||||
originalFileContentById.current.set(selectedFile.id, content);
|
||||
setDraft(buildFileDraft(selectedFile, content));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setDraft(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedEntry, selectedFile, readFile, setDraft]);
|
||||
|
||||
if (!selectedId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Icon name="plug" className="mx-auto mb-3 h-12 w-12 opacity-50" />
|
||||
<p className="typography-body">{t('settings.plugins.page.empty.select')}</p>
|
||||
<p className="typography-meta mt-1 opacity-75">
|
||||
{t('settings.plugins.page.empty.add')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedEntry && draft && draft.mode === 'entry') {
|
||||
const optionsResult = parseOptionsJson(draft.optionsJson);
|
||||
const optionsValid = optionsResult.ok;
|
||||
const isDirty =
|
||||
draft.spec !== selectedEntry.spec ||
|
||||
draft.optionsJson !== stringifyOptions(selectedEntry.options);
|
||||
|
||||
const handleEntryDiscard = () => {
|
||||
setDraft(buildEntryDraft(selectedEntry));
|
||||
};
|
||||
|
||||
const handleEntrySave = async () => {
|
||||
if (!optionsValid) return;
|
||||
const spec = draft.spec.trim();
|
||||
if (!spec) {
|
||||
toast.error(t('settings.plugins.validation.specRequired'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await updateEntry(selectedEntry.id, {
|
||||
spec,
|
||||
options: optionsResult.value,
|
||||
});
|
||||
if (result.ok) {
|
||||
if (result.reloadFailed) {
|
||||
toast.warning(
|
||||
result.message || t('settings.plugins.toast.reloadFailed'),
|
||||
{ description: result.warning },
|
||||
);
|
||||
} else {
|
||||
toast.success(result.message || t('settings.plugins.toast.updated'));
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message || t('settings.plugins.toast.reloadFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsPageLayout>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{t('settings.plugins.page.header.entry')}
|
||||
</h2>
|
||||
<ScopeBadge
|
||||
scope={selectedEntry.scope}
|
||||
label={
|
||||
selectedEntry.scope === 'project'
|
||||
? t('settings.plugins.sidebar.group.projectEntries')
|
||||
: t('settings.plugins.sidebar.group.userEntries')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RegistryBanner entryId={selectedEntry.id} spec={selectedEntry.spec} />
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-meta text-muted-foreground">
|
||||
{t('settings.plugins.page.field.spec')}
|
||||
</label>
|
||||
<Input
|
||||
value={draft.spec}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, spec: e.target.value })
|
||||
}
|
||||
placeholder={t('settings.plugins.page.field.spec.placeholder')}
|
||||
className="font-mono typography-meta"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-meta text-muted-foreground">
|
||||
{t('settings.plugins.page.field.options')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={draft.optionsJson}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, optionsJson: e.target.value })
|
||||
}
|
||||
rows={10}
|
||||
className={cn(
|
||||
'font-mono typography-meta min-h-[200px]',
|
||||
!optionsValid && 'border-[var(--status-error-border)]',
|
||||
)}
|
||||
spellCheck={false}
|
||||
placeholder='{ }'
|
||||
/>
|
||||
{!optionsValid && (
|
||||
<p className="typography-micro text-[var(--status-error)]">
|
||||
{t('settings.plugins.page.field.options.invalidJson')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void handleEntrySave()}
|
||||
disabled={!isDirty || !optionsValid || isSaving}
|
||||
>
|
||||
{t('settings.plugins.page.action.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleEntryDiscard}
|
||||
disabled={!isDirty || isSaving}
|
||||
>
|
||||
{t('settings.plugins.page.action.discard')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedFile && draft && draft.mode === 'file') {
|
||||
const originalContent = originalFileContentById.current.get(selectedFile.id) ?? '';
|
||||
const isDirty = draft.content !== originalContent || draft.fileName !== selectedFile.fileName;
|
||||
|
||||
const handleFileDiscard = () => {
|
||||
void (async () => {
|
||||
setIsLoadingFile(true);
|
||||
const result = await readFile(selectedFile.id);
|
||||
const content = result?.content ?? '';
|
||||
setIsLoadingFile(false);
|
||||
originalFileContentById.current.set(selectedFile.id, content);
|
||||
setDraft(buildFileDraft(selectedFile, content));
|
||||
})();
|
||||
};
|
||||
|
||||
const handleFileSave = async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const result = await updateFile(selectedFile.id, { content: draft.content });
|
||||
if (result.ok) {
|
||||
originalFileContentById.current.set(selectedFile.id, draft.content);
|
||||
if (result.reloadFailed) {
|
||||
toast.warning(
|
||||
result.message || t('settings.plugins.toast.reloadFailed'),
|
||||
{ description: result.warning },
|
||||
);
|
||||
} else {
|
||||
toast.success(result.message || t('settings.plugins.toast.updated'));
|
||||
}
|
||||
} else {
|
||||
toast.error(result.message || t('settings.plugins.toast.reloadFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsPageLayout>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-wrap">
|
||||
<h2 className="typography-ui-header font-semibold text-foreground truncate">
|
||||
{t('settings.plugins.page.header.file')}
|
||||
</h2>
|
||||
<ScopeBadge
|
||||
scope={selectedFile.scope}
|
||||
label={
|
||||
selectedFile.scope === 'project'
|
||||
? t('settings.plugins.sidebar.group.projectFiles')
|
||||
: t('settings.plugins.sidebar.group.userFiles')
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'typography-micro font-mono rounded-full px-2 py-0.5',
|
||||
'bg-[var(--surface-elevated)] text-foreground',
|
||||
'border border-[var(--interactive-border)]',
|
||||
)}
|
||||
>
|
||||
{selectedFile.fileName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="typography-meta text-muted-foreground">
|
||||
{t('settings.plugins.page.field.content')}
|
||||
</label>
|
||||
<Textarea
|
||||
value={draft.content}
|
||||
onChange={(e) =>
|
||||
setDraft({ ...draft, content: e.target.value })
|
||||
}
|
||||
rows={16}
|
||||
className="font-mono typography-meta min-h-[320px]"
|
||||
spellCheck={false}
|
||||
disabled={isLoadingFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void handleFileSave()}
|
||||
disabled={!isDirty || isSaving || isLoadingFile}
|
||||
>
|
||||
{t('settings.plugins.page.action.save')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleFileDiscard}
|
||||
disabled={isSaving || isLoadingFile}
|
||||
>
|
||||
{t('settings.plugins.page.action.discard')}
|
||||
</Button>
|
||||
</div>
|
||||
</SettingsPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Icon name="loader-4" className="mx-auto mb-3 h-6 w-6 animate-spin opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,397 @@
|
||||
import React from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { AddPluginDialog } from './AddPluginDialog';
|
||||
import { RegistryBadge } from './RegistryBadge';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { SettingsSidebarLayout } from '@/components/sections/shared/SettingsSidebarLayout';
|
||||
import { SettingsSidebarItem } from '@/components/sections/shared/SettingsSidebarItem';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
usePluginsStore,
|
||||
type PluginEntry,
|
||||
type PluginFile,
|
||||
} from '@/stores/usePluginsStore';
|
||||
|
||||
interface PluginsSidebarProps {
|
||||
onItemSelect?: () => void;
|
||||
onAddClick?: () => void;
|
||||
}
|
||||
|
||||
type DeleteTarget =
|
||||
| { kind: 'entry'; id: string; label: string }
|
||||
| { kind: 'file'; id: string; label: string }
|
||||
| null;
|
||||
|
||||
const entryIcon = (entry: PluginEntry): IconName =>
|
||||
entry.parsedKind === 'npm' ? 'code-box' : 'folder';
|
||||
|
||||
export const PluginsSidebar: React.FC<PluginsSidebarProps> = ({
|
||||
onItemSelect,
|
||||
onAddClick,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const { entries, files, selectedId, setSelected, deleteEntry, deleteFile, loadPlugins } =
|
||||
usePluginsStore(
|
||||
useShallow((s) => ({
|
||||
entries: s.entries,
|
||||
files: s.files,
|
||||
selectedId: s.selectedId,
|
||||
setSelected: s.setSelected,
|
||||
deleteEntry: s.deleteEntry,
|
||||
deleteFile: s.deleteFile,
|
||||
loadPlugins: s.loadPlugins,
|
||||
})),
|
||||
);
|
||||
|
||||
const registryInfo = usePluginsStore((s) => s.registryInfo);
|
||||
const isLoadingRegistry = usePluginsStore((s) => s.isLoadingRegistry);
|
||||
const loadRegistryInfo = usePluginsStore((s) => s.loadRegistryInfo);
|
||||
const updateToLatest = usePluginsStore((s) => s.updateToLatest);
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<DeleteTarget>(null);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [isAddOpen, setIsAddOpen] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadPlugins();
|
||||
}, [loadPlugins]);
|
||||
|
||||
const updateCounts = React.useMemo(() => {
|
||||
const counts = { userEntries: 0, projectEntries: 0 };
|
||||
for (const entry of entries) {
|
||||
const info = registryInfo[entry.spec];
|
||||
if (info?.kind === 'npm-ok' && info.hasUpdate) {
|
||||
if (entry.scope === 'user') counts.userEntries++;
|
||||
else if (entry.scope === 'project') counts.projectEntries++;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [entries, registryInfo]);
|
||||
|
||||
const userEntries = React.useMemo(
|
||||
() => entries.filter((e) => e.scope === 'user'),
|
||||
[entries],
|
||||
);
|
||||
const projectEntries = React.useMemo(
|
||||
() => entries.filter((e) => e.scope === 'project'),
|
||||
[entries],
|
||||
);
|
||||
const userFiles = React.useMemo(
|
||||
() => files.filter((f) => f.scope === 'user'),
|
||||
[files],
|
||||
);
|
||||
const projectFiles = React.useMemo(
|
||||
() => files.filter((f) => f.scope === 'project'),
|
||||
[files],
|
||||
);
|
||||
|
||||
const total = entries.length + files.length;
|
||||
const isEmpty = total === 0;
|
||||
|
||||
const handleAdd = React.useCallback(() => {
|
||||
if (onAddClick) {
|
||||
onAddClick();
|
||||
} else {
|
||||
setIsAddOpen(true);
|
||||
}
|
||||
}, [onAddClick]);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(id: string) => {
|
||||
setSelected(id);
|
||||
onItemSelect?.();
|
||||
},
|
||||
[onItemSelect, setSelected],
|
||||
);
|
||||
|
||||
const handleUpdateToLatest = React.useCallback(
|
||||
async (id: string) => {
|
||||
const entry = entries.find((e) => e.id === id);
|
||||
if (!entry) return;
|
||||
const info = registryInfo[entry.spec];
|
||||
if (!info || info.kind !== 'npm-ok' || !info.hasUpdate || !info.latestVersion) {
|
||||
return;
|
||||
}
|
||||
const latest = info.latestVersion;
|
||||
const result = await updateToLatest(id);
|
||||
if (result.ok) {
|
||||
toast.success(
|
||||
t('settings.plugins.toast.updatedToLatest', { version: latest }),
|
||||
);
|
||||
} else {
|
||||
toast.error(t('settings.plugins.toast.refreshFailed'));
|
||||
}
|
||||
},
|
||||
[entries, registryInfo, t, updateToLatest],
|
||||
);
|
||||
|
||||
const handleRefresh = React.useCallback(async () => {
|
||||
toast.info(t('settings.plugins.toast.refreshing'));
|
||||
try {
|
||||
await loadRegistryInfo({ force: true });
|
||||
} catch {
|
||||
toast.error(t('settings.plugins.toast.refreshFailed'));
|
||||
}
|
||||
}, [loadRegistryInfo, t]);
|
||||
|
||||
const handleDelete = React.useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
setIsDeleting(true);
|
||||
const result =
|
||||
deleteTarget.kind === 'entry'
|
||||
? await deleteEntry(deleteTarget.id)
|
||||
: await deleteFile(deleteTarget.id);
|
||||
if (result.ok) {
|
||||
toast.success(
|
||||
result.message ||
|
||||
t('settings.plugins.sidebar.toast.deleted', { name: deleteTarget.label }),
|
||||
);
|
||||
} else {
|
||||
toast.error(t('settings.plugins.sidebar.toast.deleteFailed'));
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
setIsDeleting(false);
|
||||
}, [deleteEntry, deleteFile, deleteTarget, t]);
|
||||
|
||||
const renderEntry = (entry: PluginEntry) => {
|
||||
const info = registryInfo[entry.spec];
|
||||
const canUpdate =
|
||||
info?.kind === 'npm-ok' && info.hasUpdate && !!info.latestVersion;
|
||||
const actions: Array<{
|
||||
label: string;
|
||||
icon?: IconName;
|
||||
destructive?: boolean;
|
||||
onClick: () => void;
|
||||
}> = [];
|
||||
if (canUpdate) {
|
||||
actions.push({
|
||||
label: t('settings.plugins.sidebar.actions.updateToLatest'),
|
||||
icon: 'arrow-up-s',
|
||||
onClick: () => void handleUpdateToLatest(entry.id),
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
label: t('settings.common.actions.delete'),
|
||||
icon: 'delete-bin',
|
||||
destructive: true,
|
||||
onClick: () =>
|
||||
setDeleteTarget({ kind: 'entry', id: entry.id, label: entry.spec }),
|
||||
});
|
||||
return (
|
||||
<SettingsSidebarItem
|
||||
key={entry.id}
|
||||
title={
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="min-w-0 flex-1 truncate">{entry.spec}</span>
|
||||
<RegistryBadge spec={entry.spec} />
|
||||
</span>
|
||||
}
|
||||
metadata={
|
||||
entry.parsedKind === 'npm'
|
||||
? t('settings.plugins.sidebar.kind.npm')
|
||||
: t('settings.plugins.sidebar.kind.path')
|
||||
}
|
||||
selected={selectedId === entry.id}
|
||||
onSelect={() => handleSelect(entry.id)}
|
||||
icon={
|
||||
<Icon
|
||||
name={entryIcon(entry)}
|
||||
className="h-4 w-4 flex-shrink-0 text-muted-foreground/70"
|
||||
/>
|
||||
}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFile = (file: PluginFile) => (
|
||||
<SettingsSidebarItem
|
||||
key={file.id}
|
||||
title={file.fileName}
|
||||
metadata={t('settings.plugins.sidebar.kind.file')}
|
||||
selected={selectedId === file.id}
|
||||
onSelect={() => handleSelect(file.id)}
|
||||
icon={
|
||||
<Icon
|
||||
name="file-text"
|
||||
className="h-4 w-4 flex-shrink-0 text-muted-foreground/70"
|
||||
/>
|
||||
}
|
||||
actions={[
|
||||
{
|
||||
label: t('settings.common.actions.delete'),
|
||||
icon: 'delete-bin',
|
||||
destructive: true,
|
||||
onClick: () =>
|
||||
setDeleteTarget({ kind: 'file', id: file.id, label: file.fileName }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderGroup = (
|
||||
label: string,
|
||||
children: React.ReactNode,
|
||||
updateCount = 0,
|
||||
) => (
|
||||
<>
|
||||
<div className="px-2 pb-1.5 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
{updateCount > 0 && (
|
||||
<span className="ml-2 normal-case font-normal text-[var(--status-success)]">
|
||||
{t(
|
||||
updateCount === 1
|
||||
? 'settings.plugins.sidebar.group.updatesAvailable_one'
|
||||
: 'settings.plugins.sidebar.group.updatesAvailable_other',
|
||||
{ count: updateCount },
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSidebarLayout
|
||||
variant="background"
|
||||
header={
|
||||
<div className="border-b px-3 pt-4 pb-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-foreground">
|
||||
{t('settings.plugins.sidebar.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{t('settings.plugins.sidebar.total', { count: total })}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={() => void handleRefresh()}
|
||||
disabled={isLoadingRegistry}
|
||||
aria-label={t('settings.plugins.sidebar.actions.refresh')}
|
||||
title={t('settings.plugins.sidebar.actions.refresh')}
|
||||
>
|
||||
<Icon
|
||||
name="refresh"
|
||||
className={
|
||||
isLoadingRegistry ? 'size-4 animate-spin' : 'size-4'
|
||||
}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 -my-1 text-muted-foreground"
|
||||
onClick={handleAdd}
|
||||
aria-label={t('settings.plugins.sidebar.actions.addTitle')}
|
||||
title={t('settings.plugins.sidebar.actions.addTitle')}
|
||||
>
|
||||
<Icon name="add" className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="py-12 px-4 text-center text-muted-foreground">
|
||||
<Icon name="plug" className="mx-auto mb-3 h-10 w-10 opacity-50" />
|
||||
<p className="typography-ui-label font-medium">
|
||||
{t('settings.plugins.sidebar.empty.title')}
|
||||
</p>
|
||||
<p className="typography-meta mt-1 opacity-75">
|
||||
{t('settings.plugins.sidebar.empty.description')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{userEntries.length > 0 &&
|
||||
renderGroup(
|
||||
t('settings.plugins.sidebar.group.userEntries'),
|
||||
userEntries.map(renderEntry),
|
||||
updateCounts.userEntries,
|
||||
)}
|
||||
{userFiles.length > 0 &&
|
||||
renderGroup(
|
||||
t('settings.plugins.sidebar.group.userFiles'),
|
||||
userFiles.map(renderFile),
|
||||
)}
|
||||
{projectEntries.length > 0 &&
|
||||
renderGroup(
|
||||
t('settings.plugins.sidebar.group.projectEntries'),
|
||||
projectEntries.map(renderEntry),
|
||||
updateCounts.projectEntries,
|
||||
)}
|
||||
{projectFiles.length > 0 &&
|
||||
renderGroup(
|
||||
t('settings.plugins.sidebar.group.projectFiles'),
|
||||
projectFiles.map(renderFile),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SettingsSidebarLayout>
|
||||
|
||||
<AddPluginDialog open={isAddOpen} onOpenChange={setIsAddOpen} />
|
||||
|
||||
<Dialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !isDeleting) setDeleteTarget(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('settings.plugins.sidebar.deleteDialog.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.plugins.sidebar.deleteDialog.description', {
|
||||
name: deleteTarget?.label ?? '',
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{t('settings.common.actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting
|
||||
? t('settings.plugins.sidebar.actions.deleting')
|
||||
: t('settings.common.actions.delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { usePluginsStore } from '@/stores/usePluginsStore';
|
||||
|
||||
interface RegistryBadgeProps {
|
||||
spec: string;
|
||||
}
|
||||
|
||||
export const RegistryBadge: React.FC<RegistryBadgeProps> = ({ spec }) => {
|
||||
const info = usePluginsStore((s) => s.registryInfo[spec]);
|
||||
const { t } = useI18n();
|
||||
|
||||
if (!info) return null;
|
||||
|
||||
const wrap = (
|
||||
trigger: React.ReactNode,
|
||||
tooltipText: string,
|
||||
): React.ReactElement => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex shrink-0 items-center gap-0.5 text-xs">
|
||||
{trigger}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
switch (info.kind) {
|
||||
case 'npm-ok': {
|
||||
if (!info.hasUpdate || !info.latestVersion) return null;
|
||||
return wrap(
|
||||
<span className="inline-flex items-center gap-0.5 text-[var(--status-success)]">
|
||||
<Icon name="arrow-up-s" className="h-3 w-3" />
|
||||
{info.latestVersion}
|
||||
</span>,
|
||||
t('settings.plugins.registry.badge.update.tooltip', {
|
||||
current: info.currentVersion ?? '',
|
||||
latest: info.latestVersion,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'npm-missing-version':
|
||||
return wrap(
|
||||
<Icon
|
||||
name="error-warning"
|
||||
className="h-3 w-3 text-[var(--status-warning)]"
|
||||
/>,
|
||||
t('settings.plugins.registry.badge.missingVersion.tooltip', {
|
||||
version: info.currentVersion,
|
||||
name: info.name,
|
||||
}),
|
||||
);
|
||||
case 'npm-missing-package':
|
||||
return wrap(
|
||||
<Icon
|
||||
name="error-warning"
|
||||
className="h-3 w-3 text-[var(--status-error)]"
|
||||
/>,
|
||||
t('settings.plugins.registry.badge.missingPackage.tooltip', {
|
||||
name: info.name,
|
||||
}),
|
||||
);
|
||||
case 'npm-malformed':
|
||||
return wrap(
|
||||
<Icon
|
||||
name="error-warning"
|
||||
className="h-3 w-3 text-[var(--status-error)]"
|
||||
/>,
|
||||
t('settings.plugins.registry.badge.malformed.tooltip'),
|
||||
);
|
||||
case 'npm-network':
|
||||
return wrap(
|
||||
<Icon name="cloud-off" className="h-3 w-3 text-muted-foreground" />,
|
||||
t('settings.plugins.registry.badge.network.tooltip'),
|
||||
);
|
||||
case 'path-missing':
|
||||
return wrap(
|
||||
<Icon
|
||||
name="error-warning"
|
||||
className="h-3 w-3 text-[var(--status-error)]"
|
||||
/>,
|
||||
t('settings.plugins.registry.badge.pathMissing.tooltip', {
|
||||
path: info.absolutePath,
|
||||
}),
|
||||
);
|
||||
case 'path-unreadable':
|
||||
return wrap(
|
||||
<Icon
|
||||
name="error-warning"
|
||||
className="h-3 w-3 text-[var(--status-warning)]"
|
||||
/>,
|
||||
t('settings.plugins.registry.badge.pathUnreadable.tooltip', {
|
||||
path: info.absolutePath,
|
||||
}),
|
||||
);
|
||||
case 'path-ok':
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from '@/components/ui';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { usePluginsStore } from '@/stores/usePluginsStore';
|
||||
|
||||
interface RegistryBannerProps {
|
||||
entryId: string;
|
||||
spec: string;
|
||||
}
|
||||
|
||||
export const RegistryBanner: React.FC<RegistryBannerProps> = ({ entryId, spec }) => {
|
||||
const { t } = useI18n();
|
||||
const info = usePluginsStore((s) => s.registryInfo[spec]);
|
||||
const updateToLatest = usePluginsStore((s) => s.updateToLatest);
|
||||
|
||||
const [isUpdating, setIsUpdating] = React.useState(false);
|
||||
|
||||
if (!info) return null;
|
||||
|
||||
if (info.kind === 'npm-ok') {
|
||||
if (!info.hasUpdate || !info.latestVersion) return null;
|
||||
|
||||
const latest = info.latestVersion;
|
||||
const current = info.currentVersion ?? '';
|
||||
|
||||
const handleUpdate = async () => {
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const result = await updateToLatest(entryId);
|
||||
if (result.ok) {
|
||||
toast.success(
|
||||
t('settings.plugins.toast.updatedToLatest', { version: latest }),
|
||||
);
|
||||
} else {
|
||||
toast.error(t('settings.plugins.toast.refreshFailed'));
|
||||
}
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card p-3 flex items-start gap-3">
|
||||
<Icon
|
||||
name="arrow-up"
|
||||
className="h-5 w-5 text-[var(--status-success)] shrink-0 mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="typography-label text-[var(--status-success)]">
|
||||
{t('settings.plugins.registry.banner.updateAvailable.title')}
|
||||
</p>
|
||||
<p className="typography-micro text-muted-foreground mt-0.5">
|
||||
{t('settings.plugins.registry.banner.updateAvailable.description', {
|
||||
current,
|
||||
latest,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => void handleUpdate()}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{t('settings.plugins.registry.banner.updateAvailable.action', { latest })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (info.kind === 'path-ok') return null;
|
||||
|
||||
if (info.kind === 'npm-network') {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card p-3 flex items-start gap-3">
|
||||
<Icon
|
||||
name="cloud-off"
|
||||
className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="typography-micro text-muted-foreground">
|
||||
{t('settings.plugins.registry.badge.network.tooltip')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isWarning = info.kind === 'path-unreadable';
|
||||
const colorVar = isWarning ? 'var(--status-warning)' : 'var(--status-error)';
|
||||
const iconName = isWarning ? 'alert' : 'error-warning';
|
||||
|
||||
const description = (() => {
|
||||
switch (info.kind) {
|
||||
case 'npm-missing-version':
|
||||
return t('settings.plugins.registry.banner.invalid.missingVersion');
|
||||
case 'npm-missing-package':
|
||||
return t('settings.plugins.registry.banner.invalid.missingPackage');
|
||||
case 'npm-malformed':
|
||||
return t('settings.plugins.registry.banner.invalid.malformed');
|
||||
case 'path-missing':
|
||||
return t('settings.plugins.registry.banner.invalid.pathMissing');
|
||||
case 'path-unreadable':
|
||||
return t('settings.plugins.registry.banner.invalid.pathUnreadable');
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-card p-3 flex items-start gap-3">
|
||||
<Icon
|
||||
name={iconName}
|
||||
className="h-5 w-5 shrink-0 mt-0.5"
|
||||
style={{ color: colorVar }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="typography-label" style={{ color: colorVar }}>
|
||||
{t('settings.plugins.registry.banner.invalid.title')}
|
||||
</p>
|
||||
<p className="typography-micro text-muted-foreground mt-0.5">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { PluginsSidebar } from './PluginsSidebar';
|
||||
export { PluginsPage } from './PluginsPage';
|
||||
export { AddPluginDialog } from './AddPluginDialog';
|
||||
@@ -17,6 +17,8 @@ import { CommandsSidebar } from '@/components/sections/commands/CommandsSidebar'
|
||||
import { CommandsPage } from '@/components/sections/commands/CommandsPage';
|
||||
import { McpSidebar } from '@/components/sections/mcp/McpSidebar';
|
||||
import { McpPage } from '@/components/sections/mcp/McpPage';
|
||||
import { PluginsSidebar, PluginsPage } from '@/components/sections/plugins';
|
||||
import { usePluginsStore } from '@/stores/usePluginsStore';
|
||||
import { SkillsSidebar } from '@/components/sections/skills/SkillsSidebar';
|
||||
import { SkillsPage } from '@/components/sections/skills/SkillsPage';
|
||||
import { ProjectsSidebar } from '@/components/sections/projects/ProjectsSidebar';
|
||||
@@ -88,6 +90,7 @@ const pageOrder: SettingsPageSlug[] = [
|
||||
'behavior',
|
||||
'commands',
|
||||
'mcp',
|
||||
'plugins',
|
||||
'providers',
|
||||
'usage',
|
||||
'skills.installed',
|
||||
@@ -174,6 +177,8 @@ export function getSettingsNavIcon(slug: SettingsPageSlug): IconName | null {
|
||||
return 'slash-commands-2';
|
||||
case 'mcp':
|
||||
return 'plug-2';
|
||||
case 'plugins':
|
||||
return 'code-box';
|
||||
|
||||
case 'skills.installed':
|
||||
return 'book-open';
|
||||
@@ -397,6 +402,10 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
void useMcpConfigStore.getState().loadMcpConfigs();
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'plugins') {
|
||||
void usePluginsStore.getState().loadPlugins();
|
||||
return;
|
||||
}
|
||||
if (settingsSlug === 'skills.installed' || settingsSlug === 'skills.catalog') {
|
||||
void useSkillsStore.getState().loadSkills();
|
||||
void useSkillsCatalogStore.getState().loadCatalog();
|
||||
@@ -454,6 +463,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return t('settings.page.commands.title');
|
||||
case 'mcp':
|
||||
return t('settings.page.mcp.title');
|
||||
case 'plugins':
|
||||
return t('settings.page.plugins.title');
|
||||
case 'skills.installed':
|
||||
return t('settings.page.skills.title');
|
||||
case 'skills.catalog':
|
||||
@@ -507,6 +518,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <CommandsSidebar onItemSelect={opts.onItemSelect} />;
|
||||
case 'mcp':
|
||||
return <McpSidebar onItemSelect={opts.onItemSelect} />;
|
||||
case 'plugins':
|
||||
return <PluginsSidebar onItemSelect={opts.onItemSelect} />;
|
||||
case 'skills.installed':
|
||||
return <SkillsSidebar onItemSelect={opts.onItemSelect} />;
|
||||
case 'providers':
|
||||
@@ -543,6 +556,8 @@ export const SettingsView: React.FC<SettingsViewProps> = ({ onClose, forceMobile
|
||||
return <CommandsPage />;
|
||||
case 'mcp':
|
||||
return <McpPage />;
|
||||
case 'plugins':
|
||||
return <PluginsPage />;
|
||||
case 'skills.installed':
|
||||
return <SkillsPage view="installed" />;
|
||||
case 'skills.catalog':
|
||||
|
||||
Reference in New Issue
Block a user