/** * The "add plugin" modals (Side card settings → the dashed cards at the * end of the 侧边栏内容 / 文件预览 grids): declare that the sidebar's * extension points — tab pages and file previewers — are open to plugins * (registered through `ctx.betterSidebar`), point at the GitHub topic page * for discovery, and show the repo's recommended plugin catalog of the * matching kind (name / url / description / install script). * * Per entry there are two actions: * - 「跳转」opens the plugin's repo in a REAL new browser tab (window.open * — a button, so the sidebar link takeover cannot reroute it); * - 「安装」only COPIES the install script to the clipboard (writeClipboard) * with a transient "已复制" feedback on the button — the user pastes and * runs it wherever they manage their DSH profile. No terminal is opened, * nothing is closed, nothing can fail outward. * * The body is extracted as {@link PluginListBody} so tests render it * directly — the Modal primitive runs hooks unconditionally, so an open * Modal must never be renderToString'd (same rule as the settingsFor popup * in SideCardSection); the modal itself mounts only while open. */ import { useState, type ReactNode } from 'react' import { Modal, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives' import type { BetterSidebarService } from './service.ts' import { PLUGIN_TOPIC_URL, type PluginEntry } from './plugins-shared.ts' import { builtinTabPlugins } from './plugins-tabs.ts' import { builtinViewerPlugins } from './plugins-viewers.ts' import { t } from './locales.ts' import css from './SideCardSection.module.css' /** Which extension point the modal is adding a plugin for. */ export type PluginKind = 'tab' | 'viewer' /** The catalog of one kind (kept in two repo files: plugins-tabs.ts / * plugins-viewers.ts). */ function catalogOf(kind: PluginKind): readonly PluginEntry[] { return kind === 'tab' ? builtinTabPlugins : builtinViewerPlugins } /** How long the "已复制" feedback stays on the copy button. */ const COPIED_FEEDBACK_MS = 1500 /** The modal body: the GitHub topic button + the recommended plugin list * with per-entry jump/copy buttons (extracted for direct testing). */ export function PluginListBody(props: { service: BetterSidebarService; kind: PluginKind }) { const { kind } = props // Which entry's copy button currently shows the "已复制" feedback. const [copiedId, setCopiedId] = useState(null) // Live catalog filter (name / id / description). A free-text search keeps // the list usable once the catalogs grow beyond a handful of entries: // every keystroke narrows the rendered rows, so a long catalog never // becomes a wall of scrolling. const [query, setQuery] = useState('') const catalog = catalogOf(kind) const needle = query.trim().toLowerCase() const matches = (entry: PluginEntry): boolean => { if (needle === '') return true const name = typeof entry.name === 'function' ? entry.name() : entry.name const description = typeof entry.description === 'function' ? entry.description() : entry.description return name.toLowerCase().includes(needle) || entry.id.toLowerCase().includes(needle) || description.toLowerCase().includes(needle) } const filtered = catalog.filter(matches) // Group by the optional category (stable order: category order of first // appearance, uncategorized entries last under the plain list). const groups = new Map() for (const entry of filtered) { const key = entry.category === undefined ? undefined : (typeof entry.category === 'function' ? entry.category() : entry.category) const list = groups.get(key) if (list === undefined) groups.set(key, [entry]) else list.push(entry) } /** Copy the entry's install script to the clipboard and flash the button's * "已复制" label for a moment. The feedback ONLY appears after a * successful write — when the clipboard is unavailable or denied * (writeClipboard resolves false) nothing is shown, so the user is never * told to paste a command that was not placed on the clipboard. Never * closes anything, never throws outward. */ const copy = async (entry: PluginEntry): Promise => { const written = await writeClipboard(entry.install) if (!written) return setCopiedId(entry.id) window.setTimeout(() => { setCopiedId(current => (current === entry.id ? null : current)) }, COPIED_FEEDBACK_MS) } /** Open the plugin's repo in a REAL new browser tab (window.open — a * button, so the sidebar link takeover cannot reroute it). */ const jump = (entry: PluginEntry): void => { window.open(entry.url, '_blank', 'noopener') } /** One catalog row (extracted so the group render stays flat). The name * resolves like the description (string or () => string) so it follows * the active locale; a plain-string entry keeps its raw name. */ const renderEntry = (entry: PluginEntry): ReactNode => { const name = typeof entry.name === 'function' ? entry.name() : entry.name return (
{/* The name is a BUTTON on the same window.open path as the jump button: as an anchor it would be caught by the document-capture link takeover (which ignores target=_blank) and land in the sidebar browser. */}
{typeof entry.description === 'function' ? entry.description() : entry.description}
{entry.install}
) } return (
{ setQuery(event.currentTarget.value) }} />
{t('addPluginsRecommended')} {filtered.length}
{catalog.length === 0 ? (
{t('addPluginsEmpty')}
) : filtered.length === 0 ? (
{t('addPluginsNoMatch')}
) : (
{[...groups.entries()].map(([category, entries]) => (
{category !== undefined && (
{category}
)} {entries.map(renderEntry)}
))}
)}
) } /** The modal itself (mounted only while open — see the module comment). */ export function AddPluginModal(props: { service: BetterSidebarService; onClose: () => void; kind: PluginKind }) { const { service, onClose, kind } = props return ( {t('settingsDone')} )} > ) }