Files
openchamber/packages/ui/src/components/sections/plugins/AddPluginDialog.tsx
T
Bohdan Triapitsyn 079e3a9bd6 feat(settings): add item search (#1592)
Adds item-level search inside Settings so users can find concrete settings like provider auth, agent mode, terminal font size, tunnel options, notification events, and similar controls instead of only filtering top-level pages.
Groups search results by Settings page and shows localized labels plus optional descriptions where useful.
Supports keyboard navigation with Arrow Up/Down, Enter, and Escape, matching the existing autocomplete interaction style.
Opens the correct Settings page or split-page draft state before scrolling to the matching control.
Highlights the matched setting with a subtle token-based background so users can see where they landed without an aggressive outline.
Adds explicit data-settings-item anchors across Settings pages and a centralized search registry with runtime/mobile availability guards.
Updates Settings UI skill guidance so future Settings changes keep search registry entries, anchors, localization, and availability guards in sync.
2026-06-10 12:15:15 +03:00

305 lines
10 KiB
TypeScript

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 data-settings-item="plugins.spec" 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 data-settings-item="plugins.options" 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 data-settings-item="plugins.content" 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;