Compare commits

...
Author SHA1 Message Date
陈大猫 6ba1b7b34a fix(plugins): migrate remaining permission fixture hosts 2026-10-29 13:56:21 +08:00
陈大猫 c309a455ed fix(plugins): contain host-view errors, isolate editing, and migrate fixtures 2026-10-29 13:42:53 +08:00
bincxz 39a0008876 Merge remote-tracking branch 'origin/main' into codex/host-rendered-plugin-system 2026-10-29 11:34:49 +08:00
bincxz f5a802d610 feat: rebuild plugin pages with host rendering and native settings 2026-10-29 11:34:40 +08:00
陈大猫 7d5d234fdd fix(host-browser): accept invalid TLS certs for page tabs (#4050)
Fixes #4049

Host Browser page tabs accept self-signed / private-CA certificate errors so HTTPS device UIs load.

Gate: GitHub Codex clean + local Codex CLEAN tip 5ec2bd4a0; CI green; Electron smoke PASS NC_OUT pr-4050-5ec2bd4a0-cert-r2.
No release.
2026-10-28 19:12:01 -07:00
陈大猫 eb56936e9b Prevent cloud sync from stopping active agent turns (#4045)
Fixes #4044.
2026-10-28 04:13:49 -07:00
陈大猫 6da42ebc53 feat(settings): custom temp directory for SFTP staging (#4035)
Fixes #4033

User-configurable Netcatty temp root (Settings → System → Temporary Files) with backward-compatible default, Choose folder / manual path / Reset. Electron smoke PASS tip f6f32b69.
2026-10-28 03:02:39 -07:00
陈大猫andCursor f0483488e1 feat(plugins): polish manager metadata and support verified README images (#4043)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-28 16:32:41 +08:00
陈大猫 263133f2ea Fix select-before-connect for grid and list hosts (#4042)
* Fix select before connect in host collections

* ci: retrigger lint-and-test (notes persistence flake unrelated to #4042)

* chore: retrigger CI after unrelated NotesManager.persistence flake
2026-10-28 01:28:27 -07:00
陈大猫 887c5779b5 fix(ai): submit PowerShell agent wrapper with CR only [4036]
* fix(ai): submit PowerShell agent wrapper with CR only [4036]

* ci: retrigger lint-and-test after runner cancel [4036]

Previous lint-and-test job was cancelled mid-step (Lint passed; Test skipped).
No code change.

* ci: retrigger lint-and-test (stuck cancelled job on prior tip) [4036]
2026-10-28 01:03:28 -07:00
27f26d8e69 feat(monitor): 优化磁盘IO展示与系统分区告警 [4030] (#4031)
* feat(monitor): 优化磁盘IO展示与系统分区告警 [4030]

* fix(monitor): 修复磁盘监控评审问题 [4030]

* fix(monitor): align Partitions bars with Memory; hide bare / label

Label-above-bar for partition list; single-root `/` hides the label and
centers the bar like Memory/CPU (unchanged). Multi-disk keeps top align +
system alerts. Disk IO adds a spacer row to track usage labels.

Review crops under .github/review/ for PR discussion (drop before merge OK).

* fix(monitor): keep bare-/ align; multi-disk labels stay left

Address Chris density feedback: keep the single root label hidden and vertically centered with Memory, and restore side-by-side labels for multiple disks. Remove the Disk I/O spacer and update the review crops.

* chore(monitor): drop PR #4031 discussion review crops

---------

Co-authored-by: wendeng.dong <wendeng.dong@sailuntire.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-27 23:54:58 -07:00
陈大猫 30a74f7c1d refactor(settings): simplify AI tool access configuration (#3957)
Move infrequent Tool Access / MCP / privacy / HTTP setup into focused dialogs; keep status and clear actions on the main page. Rebased onto current main after locale/settings conflicts.

Codex CLEAN (gpt-6.1-terra). CI lint-and-test green. Focused privacy/MCP/guidance tests passed on rebase tip. No release.
2026-10-27 20:59:11 -07:00
陈大猫 631d04b654 Fix Deck parenthesized prompt readiness (#4029)
* fix: recognize parenthesized Deck shell prompts

* fix: recognize Deck prompts with special path characters
2026-10-27 20:58:50 -07:00
313 changed files with 11057 additions and 4686 deletions

No files matched your search

+3
View File
@@ -170,3 +170,6 @@ build_with_vs2022.bat
/resources/et/*/et.exe
/resources/et/*/et-*-dlls/
/resources/et/*/*.dll
# Local plugin example staging and bundles
/examples/plugins/*/.package/
+5
View File
@@ -127,6 +127,11 @@ second private RPC shape when extending the host.
- `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and
protocol-failure containment. Runtime identity is assigned by the host and is
never accepted from request parameters.
- Custom views use only host-rendered DOM: plugin scripts stay in the isolated
runtime; the host validates incoming trees, styles, resources and events before
rendering. Never reintroduce native child views, iframe UI, or plugin code in
the host renderer. See `docs/plugin-platform/host-rendered-ui.md` for authoring,
lifecycle, limits and verification.
- App quit goes through `runPluginShutdown()` after the dirty-editor guard; do
not add another independent quit interception path.
+2 -1
View File
@@ -16,6 +16,7 @@ import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardin
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
type AppContextGetter = () => Record<string, any>;
@@ -264,7 +265,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
}
}
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || isPluginViewEditingTarget(target);
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
+3
View File
@@ -59,6 +59,7 @@ import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
@@ -1156,6 +1157,8 @@ export function AppSideEffects() {
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (!topmostDialogClose && isPluginViewEditingTarget(document.activeElement)) return;
const intent = resolveWindowCommandCloseIntent({
activeTabId: activeTabStore.getActiveTabId(),
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
+23
View File
@@ -819,4 +819,27 @@ export const enAiMessages: Messages = {
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default',
"ai.toolSettings.immediate": "Changes take effect immediately. Closing this window does not undo them.",
"ai.toolSettings.privacyOn": "Text redaction enabled",
"ai.toolSettings.privacyOff": "Text redaction disabled",
"ai.toolSettings.ruleSummary": "{cidrs} additional IP ranges · {patterns} custom patterns",
"ai.toolSettings.configurePrivacy": "Configure privacy",
"ai.toolSettings.unsaved": "Unsaved changes",
"ai.toolSettings.viewSkills": "View CLI guidance",
"ai.toolSettings.viewPrompt": "View setup prompt",
"ai.toolSettings.behavior": "MCP behavior",
"ai.toolSettings.configureBehavior": "Configure behavior",
"ai.toolSettings.configureClients": "Configure clients",
"ai.toolSettings.httpOn": "HTTP enabled",
"ai.toolSettings.httpOff": "HTTP disabled",
"ai.toolSettings.loopback": "Local connections only (127.0.0.1)",
"ai.toolSettings.configureHttp": "Configure HTTP",
"ai.toolSettings.hideSecret": "Hide secret",
"ai.toolSettings.showSecret": "Show secret",
"ai.toolSettings.manualSetup": "Manual setup",
"ai.toolSettings.format": "Format",
"ai.toolSettings.connectionDetails": "Connection details",
"ai.toolSettings.conflict": "Settings changed in another window. Your draft is preserved. Cancel and reopen to load the saved settings.",
"ai.toolSettings.retry": "Retry",
"ai.toolSettings.applyingHttp": "Applying HTTP configuration...",
};
+10 -1
View File
@@ -161,7 +161,12 @@ export const enCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Clear temp files',
'settings.system.clearing': 'Clearing...',
'settings.system.clearResult': 'Deleted {deleted} file(s), {failed} failed.',
'settings.system.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
'settings.system.tempDirChoose': 'Choose folder',
'settings.system.tempDirManual': 'Enter an absolute folder path',
'settings.system.tempDirApply': 'Apply',
'settings.system.tempDirReset': 'Reset to default',
'settings.system.tempDirError': 'Unable to use this folder.',
'settings.system.tempDirectoryHint': 'Netcatty creates a Netcatty temp subfolder in the chosen folder for SFTP staging and external editing. Changing the folder during a transfer may affect that transfer.',
'settings.system.credentials.title': 'Credential Protection',
'settings.system.credentials.status': 'Status',
'settings.system.credentials.checking': 'Checking...',
@@ -1375,6 +1380,9 @@ Highlight the focused split pane:
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Official",
"settings.plugins.sources.short.thirdparty": "Third-party",
"settings.plugins.sources.short.local": "Local",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
@@ -1393,6 +1401,7 @@ Highlight the focused split pane:
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Plugin settings",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.',
+4
View File
@@ -1,4 +1,8 @@
export const enMonitorMessages = {
"monitor.systemDisks": "System partitions ({count})",
"monitor.systemDisksAlert": "System partition usage exceeds 80%; expanded automatically.",
"monitor.diskIoTotal": "Total",
"monitor.partitionIoHint": "I/O of the block device backing this mount. Mounts sharing a device show the same rates. Unavailable until two valid samples or when no block device can be identified; not supported on macOS.",
"monitor.diskIo": "Disk I/O",
"monitor.diskRead": "Read",
"monitor.diskWrite": "Write",
+23
View File
@@ -819,4 +819,27 @@ export const esAiMessages: Messages = {
'zmodem.overwrite.skip': 'Omitir',
'zmodem.overwrite.cancel': 'Cancelar',
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
"ai.toolSettings.immediate": "Los cambios se aplican inmediatamente. Cerrar esta ventana no los deshace.",
"ai.toolSettings.privacyOn": "Ocultación de texto activada",
"ai.toolSettings.privacyOff": "Ocultación de texto desactivada",
"ai.toolSettings.ruleSummary": "{cidrs} rangos IP adicionales · {patterns} patrones personalizados",
"ai.toolSettings.configurePrivacy": "Configurar privacidad",
"ai.toolSettings.unsaved": "Cambios sin guardar",
"ai.toolSettings.viewSkills": "Ver instrucciones de CLI",
"ai.toolSettings.viewPrompt": "Ver instrucciones de conexión",
"ai.toolSettings.behavior": "Comportamiento de MCP",
"ai.toolSettings.configureBehavior": "Configurar comportamiento",
"ai.toolSettings.configureClients": "Configurar clientes",
"ai.toolSettings.httpOn": "HTTP activado",
"ai.toolSettings.httpOff": "HTTP desactivado",
"ai.toolSettings.loopback": "Solo conexiones locales (127.0.0.1)",
"ai.toolSettings.configureHttp": "Configurar HTTP",
"ai.toolSettings.hideSecret": "Ocultar secreto",
"ai.toolSettings.showSecret": "Mostrar secreto",
"ai.toolSettings.manualSetup": "Configuración manual",
"ai.toolSettings.format": "Formato",
"ai.toolSettings.connectionDetails": "Detalles de conexión",
"ai.toolSettings.conflict": "La configuración cambió en otra ventana. Se conserva el borrador. Cancela y vuelve a abrir para cargar la configuración guardada.",
"ai.toolSettings.retry": "Reintentar",
"ai.toolSettings.applyingHttp": "Aplicando la configuración HTTP...",
};
+10 -1
View File
@@ -161,7 +161,12 @@ export const esCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Limpiar archivos temporales',
'settings.system.clearing': 'Limpiando...',
'settings.system.clearResult': 'Se eliminaron {deleted} archivo(s), {failed} fallaron.',
'settings.system.tempDirectoryHint': 'Los archivos temporales se crean al abrir archivos remotos con aplicaciones externas. Se limpian automáticamente cuando las sesiones SFTP se cierran.',
'settings.system.tempDirChoose': 'Elegir carpeta',
'settings.system.tempDirManual': 'Introduce una ruta de carpeta absoluta',
'settings.system.tempDirApply': 'Aplicar',
'settings.system.tempDirReset': 'Restablecer predeterminado',
'settings.system.tempDirError': 'No se puede usar esta carpeta.',
'settings.system.tempDirectoryHint': 'Se usa para el almacenamiento temporal de transferencias SFTP y archivos abiertos con aplicaciones externas. Cambiar la carpeta durante una transferencia puede afectar esa transferencia.',
'settings.system.credentials.title': 'Protección de credenciales',
'settings.system.credentials.status': 'Estado',
'settings.system.credentials.checking': 'Comprobando...',
@@ -1375,6 +1380,9 @@ Resalta el panel dividido enfocado:
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Oficial",
"settings.plugins.sources.short.thirdparty": "Terceros",
"settings.plugins.sources.short.local": "Local",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
@@ -1393,6 +1401,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Ajustes del plugin",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
+4
View File
@@ -1,4 +1,8 @@
export const esMonitorMessages = {
"monitor.systemDisks": "Particiones del sistema ({count})",
"monitor.systemDisksAlert": "El uso de una partición del sistema supera el 80 %; se ha expandido automáticamente.",
"monitor.diskIoTotal": "Total",
"monitor.partitionIoHint": "E/S del dispositivo de bloques de este montaje. Los montajes del mismo dispositivo muestran las mismas tasas. No disponible hasta tener dos muestras válidas o sin un dispositivo identificable; no compatible con macOS.",
"monitor.diskIo": "E/S de disco",
"monitor.diskRead": "Lectura",
"monitor.diskWrite": "Escritura",
+23
View File
@@ -748,4 +748,27 @@ export const ruAiMessages: Messages = {
'zmodem.overwrite.skip': 'Пропустить',
'zmodem.overwrite.cancel': 'Отмена',
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
"ai.toolSettings.immediate": "Изменения применяются сразу. Закрытие окна не отменяет их.",
"ai.toolSettings.privacyOn": "Скрытие текста включено",
"ai.toolSettings.privacyOff": "Скрытие текста выключено",
"ai.toolSettings.ruleSummary": "Дополнительные диапазоны IP: {cidrs} · Пользовательские шаблоны: {patterns}",
"ai.toolSettings.configurePrivacy": "Настроить приватность",
"ai.toolSettings.unsaved": "Есть несохранённые изменения",
"ai.toolSettings.viewSkills": "Инструкции CLI",
"ai.toolSettings.viewPrompt": "Инструкции подключения",
"ai.toolSettings.behavior": "Поведение MCP",
"ai.toolSettings.configureBehavior": "Настроить поведение",
"ai.toolSettings.configureClients": "Настроить клиенты",
"ai.toolSettings.httpOn": "HTTP включён",
"ai.toolSettings.httpOff": "HTTP выключен",
"ai.toolSettings.loopback": "Только локальные подключения (127.0.0.1)",
"ai.toolSettings.configureHttp": "Настроить HTTP",
"ai.toolSettings.hideSecret": "Скрыть секрет",
"ai.toolSettings.showSecret": "Показать секрет",
"ai.toolSettings.manualSetup": "Ручная настройка",
"ai.toolSettings.format": "Формат",
"ai.toolSettings.connectionDetails": "Параметры подключения",
"ai.toolSettings.conflict": "Настройки изменены в другом окне. Черновик сохранён. Отмените и откройте окно снова, чтобы загрузить сохранённые настройки.",
"ai.toolSettings.retry": "Повторить",
"ai.toolSettings.applyingHttp": "Применение настроек HTTP...",
};
+10 -1
View File
@@ -134,7 +134,12 @@ export const ruCoreMessages: Messages = {
'settings.system.clearTempFiles': 'Очистить временные файлы',
'settings.system.clearing': 'Очистка...',
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
'settings.system.tempDirChoose': 'Выбрать папку',
'settings.system.tempDirManual': 'Введите абсолютный путь к папке',
'settings.system.tempDirApply': 'Применить',
'settings.system.tempDirReset': 'Сбросить по умолчанию',
'settings.system.tempDirError': 'Не удалось использовать эту папку.',
'settings.system.tempDirectoryHint': 'Используется для промежуточного хранения SFTP-передач и файлов, открытых во внешних приложениях. Смена папки во время передачи может повлиять на эту передачу.',
'settings.system.credentials.title': 'Защита учётных данных',
'settings.system.credentials.status': 'Статус',
'settings.system.credentials.checking': 'Проверка...',
@@ -1240,6 +1245,9 @@ export const ruCoreMessages: Messages = {
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.short.official": "Официальный",
"settings.plugins.sources.short.thirdparty": "Сторонний",
"settings.plugins.sources.short.local": "Локальный",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
@@ -1258,6 +1266,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины",
"settings.plugins.configuration": "Настройки плагина",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
+4
View File
@@ -1,4 +1,8 @@
export const ruMonitorMessages = {
"monitor.systemDisks": "Системные разделы ({count})",
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
"monitor.diskIoTotal": "Всего",
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
+23
View File
@@ -819,4 +819,27 @@ export const zhCNAiMessages: Messages = {
'zmodem.overwrite.skip': '跳过',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置为默认',
"ai.toolSettings.immediate": "更改立即生效,关闭此窗口不会撤销。",
"ai.toolSettings.privacyOn": "已启用文本脱敏",
"ai.toolSettings.privacyOff": "未启用文本脱敏",
"ai.toolSettings.ruleSummary": "{cidrs} 个附加 IP 范围 · {patterns} 条自定义规则",
"ai.toolSettings.configurePrivacy": "配置隐私",
"ai.toolSettings.unsaved": "有未保存的更改",
"ai.toolSettings.viewSkills": "查看 CLI 指引",
"ai.toolSettings.viewPrompt": "查看接入提示词",
"ai.toolSettings.behavior": "MCP 行为",
"ai.toolSettings.configureBehavior": "配置行为",
"ai.toolSettings.configureClients": "配置客户端",
"ai.toolSettings.httpOn": "HTTP 已启用",
"ai.toolSettings.httpOff": "HTTP 未启用",
"ai.toolSettings.loopback": "仅本地连接(127.0.0.1)",
"ai.toolSettings.configureHttp": "配置 HTTP",
"ai.toolSettings.hideSecret": "隐藏密钥",
"ai.toolSettings.showSecret": "显示密钥",
"ai.toolSettings.manualSetup": "手动配置",
"ai.toolSettings.format": "格式",
"ai.toolSettings.connectionDetails": "连接详情",
"ai.toolSettings.conflict": "设置已在另一窗口更改,当前草稿已保留。请取消并重新打开以加载已保存的设置。",
"ai.toolSettings.retry": "重试",
"ai.toolSettings.applyingHttp": "正在应用 HTTP 配置…",
};
+10 -1
View File
@@ -165,7 +165,12 @@ export const zhCNCoreMessages: Messages = {
'settings.system.clearTempFiles': '清理临时文件',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
'settings.system.tempDirChoose': '选择文件夹',
'settings.system.tempDirManual': '输入绝对文件夹路径',
'settings.system.tempDirApply': '应用',
'settings.system.tempDirReset': '恢复默认',
'settings.system.tempDirError': '无法使用此文件夹。',
'settings.system.tempDirectoryHint': 'Netcatty 在所选文件夹中创建 Netcatty 临时子文件夹,用于 SFTP 传输暂存和外部编辑文件。传输期间更改文件夹可能影响该传输。',
'settings.system.credentials.title': '凭据保护',
'settings.system.credentials.status': '状态',
'settings.system.credentials.checking': '检查中...',
@@ -1290,6 +1295,9 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.short.official": "官方",
"settings.plugins.sources.short.thirdparty": "第三方",
"settings.plugins.sources.short.local": "本地",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
@@ -1308,6 +1316,7 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件设置",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。',
@@ -1,4 +1,8 @@
export const zhCNMonitorMessages = {
"monitor.systemDisks": "系统分区({count})",
"monitor.systemDisksAlert": "系统分区使用率超过 80%,已自动展开。",
"monitor.diskIoTotal": "汇总",
"monitor.partitionIoHint": "此挂载点对应块设备的读写速率;共用设备的挂载点显示相同速率。取得两次有效采样前或无法识别块设备时显示 --;暂不支持 macOS。",
"monitor.diskIo": "磁盘 I/O",
"monitor.diskRead": "读取",
"monitor.diskWrite": "写入",
+23
View File
@@ -809,4 +809,27 @@ export const zhTWAiMessages: Messages = {
'zmodem.overwrite.skip': '跳過',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重設為預設值',
"ai.toolSettings.immediate": "變更立即生效,關閉此視窗不會復原。",
"ai.toolSettings.privacyOn": "已啟用文字遮蔽",
"ai.toolSettings.privacyOff": "未啟用文字遮蔽",
"ai.toolSettings.ruleSummary": "{cidrs} 個額外 IP 範圍 · {patterns} 條自訂規則",
"ai.toolSettings.configurePrivacy": "設定隱私",
"ai.toolSettings.unsaved": "有未儲存的變更",
"ai.toolSettings.viewSkills": "查看 CLI 指引",
"ai.toolSettings.viewPrompt": "查看接入提示詞",
"ai.toolSettings.behavior": "MCP 行為",
"ai.toolSettings.configureBehavior": "設定行為",
"ai.toolSettings.configureClients": "設定用戶端",
"ai.toolSettings.httpOn": "HTTP 已啟用",
"ai.toolSettings.httpOff": "HTTP 未啟用",
"ai.toolSettings.loopback": "僅本機連線(127.0.0.1)",
"ai.toolSettings.configureHttp": "設定 HTTP",
"ai.toolSettings.hideSecret": "隱藏金鑰",
"ai.toolSettings.showSecret": "顯示金鑰",
"ai.toolSettings.manualSetup": "手動設定",
"ai.toolSettings.format": "格式",
"ai.toolSettings.connectionDetails": "連線詳情",
"ai.toolSettings.conflict": "設定已在其他視窗變更,目前草稿已保留。請取消並重新開啟以載入已儲存的設定。",
"ai.toolSettings.retry": "重試",
"ai.toolSettings.applyingHttp": "正在套用 HTTP 設定…",
};
+10 -1
View File
@@ -167,7 +167,12 @@ export const zhTWCoreMessages: Messages = {
'settings.system.clearTempFiles': '清理臨時檔案',
'settings.system.clearing': '清理中...',
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。',
'settings.system.tempDirChoose': '選擇資料夾',
'settings.system.tempDirManual': '輸入絕對資料夾路徑',
'settings.system.tempDirApply': '套用',
'settings.system.tempDirReset': '恢復預設',
'settings.system.tempDirError': '無法使用此資料夾。',
'settings.system.tempDirectoryHint': '用於 SFTP 傳輸暫存與外部編輯檔案。傳輸期間變更資料夾可能影響該傳輸。',
'settings.system.credentials.title': '憑證保護',
'settings.system.credentials.status': '狀態',
'settings.system.credentials.checking': '檢查中...',
@@ -1261,6 +1266,9 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.short.official": "官方",
"settings.plugins.sources.short.thirdparty": "第三方",
"settings.plugins.sources.short.local": "本機",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
@@ -1279,6 +1287,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件設定",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
@@ -1,4 +1,8 @@
export const zhTWMonitorMessages = {
"monitor.systemDisks": "系統分割區({count})",
"monitor.systemDisksAlert": "系統分割區使用率超過 80%,已自動展開。",
"monitor.diskIoTotal": "彙總",
"monitor.partitionIoHint": "此掛載點對應區塊裝置的讀寫速率;共用裝置的掛載點顯示相同速率。取得兩次有效取樣前或無法識別區塊裝置時顯示 --;暫不支援 macOS。",
"monitor.diskIo": "磁碟 I/O",
"monitor.diskRead": "讀取",
"monitor.diskWrite": "寫入",
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
try {
const { document } = dom.window;
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
const chooser = document.createElement('div');
chooser.dataset.hostSecuritySurface = '';
document.body.append(chooser);
await flush(); assert.deepEqual(calls, [false]);
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
document.body.append(chooser); await flush();
assert.deepEqual(calls, [false, true, false]);
stop(); chooser.remove(); await flush();
assert.deepEqual(calls, [false, true, false]);
} finally { dom.window.close(); }
});
test('hidden security surfaces do not occlude a native view', async () => {
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
try {
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
assert.deepEqual(calls, []);
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, [false]); stop();
} finally { dom.window.close(); }
});
@@ -1,21 +0,0 @@
/** Native plugin views must yield while a host-owned security dialog is open. */
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
return () => observer.disconnect();
}
+5
View File
@@ -33,6 +33,7 @@ const NAMED_KEYS = new Map<string, string>([
['tab', 'tab'],
]);
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'[data-plugin-editing]',
'input',
'textarea',
'select',
@@ -44,6 +45,10 @@ const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'.monaco-menu-container',
].join(', ');
export function isPluginViewEditingTarget(node: EventTarget | null): boolean {
return Boolean((node as Element | null)?.closest?.('[data-plugin-editing]'));
}
function hasEditableShortcutAncestor(node: unknown): boolean {
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
return typeof closest === 'function'
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('React 19 changes are staged as safe data before any host rendering', async () => {
const dom = new JSDOM('<!doctype html><div id="remote"></div>', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'Event', 'MouseEvent']) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { RemoteMutationObserver } = await import('@remote-dom/core/elements');
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const staged = createPluginRemoteTree();
const snapshots: ReturnType<typeof staged.snapshot>[] = [];
const observer = new RemoteMutationObserver({
mutate(records) { snapshots.push(staged.apply(JSON.parse(JSON.stringify(records)))); },
call() { throw new Error('Host method calls are unavailable'); },
});
const container = document.getElementById('remote')!;
observer.observe(container);
const root = createRoot(container);
function Example() {
const [count, setCount] = useState(0);
return <section className="card" style={{ display: 'grid', gap: '12px' }}><h1>Hello</h1><button type="button" onClick={() => setCount((value) => value + 1)}>Count {count}</button></section>;
}
try {
await act(async () => { root.render(<Example />); await new Promise((resolve) => setTimeout(resolve, 10)); });
const first = snapshots.at(-1)!;
const section = first.children[0]; assert.equal(section.type, 1);
if (section.type !== 1) throw new Error('Expected section');
assert.equal(section.attributes.class, 'card');
assert.match(section.attributes.style, /display:\s*grid/);
await act(async () => { container.querySelector('button')!.click(); await new Promise((resolve) => setTimeout(resolve, 10)); });
const updated = JSON.stringify(snapshots.at(-1));
assert.match(updated, /Count /); assert.match(updated, /"data":"1"/);
assert.equal(staged.isFailed(), false);
assert.throws(() => staged.apply([[3, section.id, 'onclick', 'attack()']]));
assert.deepEqual(staged.snapshot(), { children: [] });
assert.equal(staged.isFailed(), true);
assert.throws(() => staged.apply([]));
} finally {
observer.disconnect(); await act(async () => { root.unmount(); });
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
test('removed attributes do not keep checkboxes, menus or old inline styles active', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox', checked: '', style: 'color:red' }, children: [] }, 0]]);
const updated = tree.apply([[3, 'control', 'checked', null], [3, 'control', 'style', null]]);
assert.deepEqual(updated.children[0], { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox' }, children: [] });
});
test('inserting a child into an existing void element fails the view', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'input', type: 1, element: 'input', attributes: {}, children: [] }, 0]]);
assert.throws(() => tree.apply([[0, 'input', { id: 'child', type: 3, data: 'invalid' }, 0]]), /Void view elements/);
assert.equal(tree.isFailed(), true);
assert.deepEqual(tree.snapshot(), { children: [] });
});
+41
View File
@@ -0,0 +1,41 @@
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { validatePluginMutationBatch, validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
type WireNode = { id: string; type: 3 | 8; data: string } | {
id: string; type: 1; element: string; attributes: Record<string, string>; children: WireNode[];
};
type WireTree = { children: WireNode[] };
/** Data-only staging area: untrusted changes are never applied directly to the host DOM. */
export function createPluginRemoteTree() {
const receiver = new RemoteReceiver();
let current: WireTree = { children: [] };
let failed = false;
const project = (node: unknown): WireNode => {
const value = node as { id: string; type: number; data?: string; element?: string; attributes?: Record<string, string>; children?: unknown[] };
if (value.type === 3 || value.type === 8) return { id: value.id, type: value.type, data: value.data ?? '' };
return { id: value.id, type: 1, element: value.element ?? '', attributes: Object.fromEntries(Object.entries(value.attributes ?? {}).filter(([, attribute]) => attribute !== null)), children: (value.children ?? []).map(project) };
};
return {
apply(input: unknown): WireTree {
if (failed) throw new Error('Plugin view has been closed after an invalid update');
try {
validatePluginMutationBatch(input);
// Ordinary DOM attribute mutations do not specify a channel. RemoteReceiver
// defaults those to properties, so explicitly mark them as attributes.
const batch = (input as unknown[][]).map((record) => record[0] === 3 && record.length === 4 ? [...record, 2] : record);
receiver.connection.mutate(batch as unknown as Parameters<typeof receiver.connection.mutate>[0]);
const next = { children: receiver.root.children.map(project) };
validatePluginRemoteTree(next);
current = next;
return next;
} catch (error) {
failed = true;
current = { children: [] };
throw error;
}
},
snapshot(): WireTree { return current; },
isFailed(): boolean { return failed; },
};
}
+2 -133
View File
@@ -1,111 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PluginViewLifecycleController,
consumeClosedPluginViewInstance,
reconcilePluginViewTabCatalog,
markPluginViewOpenTokensClosed,
reconcileClosedPluginView,
rememberClosedPluginViewInstance,
resolvePluginViewSnapshotSelection,
shouldReconcilePluginViewTabCatalog,
withdrawPluginViewTab,
type HostedPluginViewState,
} from './pluginViewLifecycle.ts';
import { PluginViewTabStore } from './pluginViewTabStore.ts';
function view(id: string, tabId?: string): HostedPluginViewState {
return {
id,
viewId: `view.${id}`,
scopeId: 'window:main',
retainContextWhenHidden: false,
...(tabId ? { tabId } : {}),
};
}
test('host close events clear the active renderer instance and identify its native tab', () => {
const current = view('active', 'plugin-view:publisher.plugin:view.active');
const result = reconcileClosedPluginView({
current,
retained: new Map([['retained', view('retained')]]),
instanceId: 'active',
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.closedTabId, current.tabId);
assert.equal(result.retained.size, 1);
});
test('host close events remove retained instances without dismissing another active view', () => {
const current = view('active');
const retainedTab = view('retained-closed', 'plugin-view:publisher.plugin:view.retained');
const result = reconcileClosedPluginView({
current,
retained: new Map([
['closed', retainedTab],
['kept', view('retained-kept')],
]),
instanceId: 'retained-closed',
});
assert.equal(result.current, current);
assert.equal(result.matchedCurrent, false);
assert.equal(result.matchedRetained, true);
assert.deepEqual([...result.retained.keys()], ['kept']);
assert.equal(result.closedTabId, retainedTab.tabId);
});
test('explicit native-tab close destroys active and retained instances instead of retaining them', () => {
const tabId = 'plugin-view:publisher.plugin:view.shared';
const result = withdrawPluginViewTab({
current: view('active', tabId),
retained: new Map([
['same-tab', view('retained-same', tabId)],
['other-tab', view('retained-other', 'plugin-view:publisher.plugin:view.other')],
]),
tabId,
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.matchedRetained, true);
assert.deepEqual(result.instanceIds, ['active', 'retained-same']);
assert.deepEqual([...result.retained.keys()], ['other-tab']);
});
test('an early host close tombstone is consumed when the open response arrives later', () => {
const tombstones = new Set<string>();
rememberClosedPluginViewInstance(tombstones, 'instance-early');
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), true);
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), false);
for (let index = 0; index < 300; index += 1) {
rememberClosedPluginViewInstance(tombstones, `instance-${index}`);
}
assert.equal(tombstones.size, 256);
assert.equal(tombstones.has('instance-0'), false);
});
test('explicit close marks only in-flight opens owned by the closed surface', () => {
const first = Symbol('first');
const second = Symbol('second');
const explicitlyClosed = new Set<symbol>();
const opening = new Map<string, Set<symbol>>([
['window:main\0view.first', new Set([first])],
['window:main\0view.second', new Set([second])],
]);
assert.equal(markPluginViewOpenTokensClosed(
opening,
explicitlyClosed,
'window:main\0view.first',
), 1);
assert.deepEqual([...explicitlyClosed], [first]);
assert.equal(markPluginViewOpenTokensClosed(opening, explicitlyClosed, null), 0);
});
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
import { PluginViewTabStore } from './pluginViewTabStore';
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
const previous = {
@@ -184,30 +80,3 @@ test('active-tab context refresh cannot withdraw the plugin tab that triggered i
assert.equal(store.getTabs()[0]?.title, 'Localized View');
assert.equal(activeTabId, tab.id);
});
test('lifecycle controller owns retained views, open tokens, tombstones, and teardown', () => {
const controller = new PluginViewLifecycleController<HostedPluginViewState>();
const tabId = 'plugin-view:publisher.plugin:view.shared';
const active = view('active', tabId);
const retained = view('retained');
controller.setCurrent(active);
controller.retain('window:main\0view.retained', retained);
const token = controller.beginOpen({
viewKey: 'window:main\0view.active',
tabId,
});
const closed = controller.handleTabClose(tabId);
assert.deepEqual(closed.instanceIds, [active.id]);
assert.equal(controller.shouldCloseOpen(token), true);
controller.finishOpen({ token, viewKey: 'window:main\0view.active', tabId });
assert.equal(controller.shouldCloseOpen(token), false);
const early = controller.handleHostClose('instance-before-open-response');
assert.equal(early.matchedCurrent, false);
assert.equal(early.matchedRetained, false);
assert.equal(controller.consumeHostClose('instance-before-open-response'), true);
assert.deepEqual(controller.drain().map((candidate) => candidate.id), [retained.id]);
assert.equal(controller.getCurrent(), null);
});
-246
View File
@@ -1,11 +1,3 @@
export interface HostedPluginViewState {
id: string;
viewId: string;
scopeId: string;
retainContextWhenHidden: boolean;
tabId?: string;
}
export interface PluginViewSnapshotSelection<T> {
requestViewId: string;
contextKey: string;
@@ -81,241 +73,3 @@ export function reconcilePluginViewTabCatalog({
store.refreshMetadata(entries);
return true;
}
export function reconcileClosedPluginView<T extends HostedPluginViewState>({
current,
retained,
instanceId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
instanceId: string;
}): {
current: T | null;
retained: Map<string, T>;
matchedCurrent: boolean;
matchedRetained: boolean;
closedTabId?: string;
} {
const matchedCurrent = current?.id === instanceId;
let matchedRetained = false;
let retainedTabId: string | undefined;
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.id === instanceId) {
matchedRetained = true;
retainedTabId = view.tabId;
}
else nextRetained.set(key, view);
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
matchedCurrent,
matchedRetained,
...((matchedCurrent ? current?.tabId : retainedTabId)
? { closedTabId: matchedCurrent ? current?.tabId : retainedTabId }
: {}),
};
}
export function withdrawPluginViewTab<T extends HostedPluginViewState>({
current,
retained,
tabId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
tabId: string;
}): {
current: T | null;
retained: Map<string, T>;
instanceIds: string[];
matchedCurrent: boolean;
matchedRetained: boolean;
} {
const matchedCurrent = current?.tabId === tabId;
let matchedRetained = false;
const instanceIds: string[] = [];
if (matchedCurrent && current) instanceIds.push(current.id);
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.tabId === tabId) {
matchedRetained = true;
instanceIds.push(view.id);
} else {
nextRetained.set(key, view);
}
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
instanceIds,
matchedCurrent,
matchedRetained,
};
}
export function rememberClosedPluginViewInstance(
tombstones: Set<string>,
instanceId: string,
limit = 256,
): void {
if (tombstones.size >= limit) {
const oldest = tombstones.values().next().value;
if (typeof oldest === 'string') tombstones.delete(oldest);
}
tombstones.add(instanceId);
}
export function consumeClosedPluginViewInstance(tombstones: Set<string>, instanceId: string): boolean {
return tombstones.delete(instanceId);
}
export function markPluginViewOpenTokensClosed(
openingTokens: ReadonlyMap<string, ReadonlySet<symbol>>,
explicitlyClosedTokens: Set<symbol>,
ownerKey: string | null | undefined,
): number {
if (!ownerKey) return 0;
const tokens = openingTokens.get(ownerKey);
if (!tokens) return 0;
for (const token of tokens) explicitlyClosedTokens.add(token);
return tokens.size;
}
export class PluginViewLifecycleController<T extends HostedPluginViewState = HostedPluginViewState> {
private current: T | null = null;
private retained = new Map<string, T>();
private readonly closedInstanceIds = new Set<string>();
private readonly openingTokensByTab = new Map<string, Set<symbol>>();
private readonly openingTokensByView = new Map<string, Set<symbol>>();
private readonly explicitlyClosedOpenTokens = new Set<symbol>();
getCurrent(): T | null {
return this.current;
}
setCurrent(view: T | null): void {
this.current = view;
}
takeCurrent(): T | null {
const current = this.current;
this.current = null;
return current;
}
retain(key: string, view: T): void {
this.retained.set(key, view);
}
takeRetained(key: string): T | null {
const retained = this.retained.get(key) ?? null;
if (retained) this.retained.delete(key);
return retained;
}
removeRetained(key: string): void {
this.retained.delete(key);
}
removeRetainedWhere(predicate: (view: T) => boolean): T[] {
const removed: T[] = [];
for (const [key, view] of this.retained) {
if (!predicate(view)) continue;
this.retained.delete(key);
removed.push(view);
}
return removed;
}
handleHostClose(instanceId: string) {
const next = reconcileClosedPluginView({
current: this.current,
retained: this.retained,
instanceId,
});
this.current = next.current;
this.retained = next.retained;
if (!next.matchedCurrent && !next.matchedRetained) {
rememberClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
return next;
}
handleTabClose(tabId: string) {
const next = withdrawPluginViewTab({
current: this.current,
retained: this.retained,
tabId,
});
this.current = next.current;
this.retained = next.retained;
markPluginViewOpenTokensClosed(
this.openingTokensByTab,
this.explicitlyClosedOpenTokens,
tabId,
);
return next;
}
markViewClosed(viewKey: string | null | undefined): number {
return markPluginViewOpenTokensClosed(
this.openingTokensByView,
this.explicitlyClosedOpenTokens,
viewKey,
);
}
beginOpen({
viewKey,
tabId,
label,
}: {
viewKey: string;
tabId?: string;
label?: string;
}): symbol {
const token = Symbol(label ?? tabId ?? viewKey);
const viewTokens = this.openingTokensByView.get(viewKey) ?? new Set<symbol>();
viewTokens.add(token);
this.openingTokensByView.set(viewKey, viewTokens);
if (tabId) {
const tabTokens = this.openingTokensByTab.get(tabId) ?? new Set<symbol>();
tabTokens.add(token);
this.openingTokensByTab.set(tabId, tabTokens);
}
return token;
}
finishOpen({ token, viewKey, tabId }: { token: symbol; viewKey: string; tabId?: string }): void {
this.explicitlyClosedOpenTokens.delete(token);
const viewTokens = this.openingTokensByView.get(viewKey);
viewTokens?.delete(token);
if (viewTokens?.size === 0) this.openingTokensByView.delete(viewKey);
if (!tabId) return;
const tabTokens = this.openingTokensByTab.get(tabId);
tabTokens?.delete(token);
if (tabTokens?.size === 0) this.openingTokensByTab.delete(tabId);
}
shouldCloseOpen(token: symbol): boolean {
return this.explicitlyClosedOpenTokens.has(token);
}
consumeHostClose(instanceId: string): boolean {
return consumeClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
drain(): T[] {
const values = [this.current, ...this.retained.values()].filter((view): view is T => Boolean(view));
this.current = null;
this.retained.clear();
this.closedInstanceIds.clear();
this.openingTokensByTab.clear();
this.openingTokensByView.clear();
this.explicitlyClosedOpenTokens.clear();
return values;
}
}
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { HostDrawnViewMessage, HostDrawnViewOpenRequest } from '@netcatty/plugin-contract';
type Update = Extract<HostDrawnViewMessage, { kind: 'host-ui.update' }> & { viewId: string };
import { PluginViewSessions } from './pluginViewSession';
function fixture() {
let updates: (data: Update) => void = () => {}; let closed: (data: NetcattyPluginViewClosedEvent) => void = () => {};
const opened: HostDrawnViewOpenRequest[] = []; const closes: string[] = []; const events: unknown[] = [];
const bridge = { openHostDrawnPluginView: async (request: HostDrawnViewOpenRequest) => { opened.push(request); return { instanceId: request.instanceId }; },
closeHostDrawnPluginView: async (id: string) => { closes.push(id); },
onHostDrawnPluginViewUpdate: (listener: typeof updates) => { updates = listener; return () => {}; },
onPluginViewClosed: (listener: typeof closed) => { closed = listener; return () => {}; },
sendHostDrawnPluginViewEvent: async (id: string, event: unknown) => { events.push({ id, event }); } };
return { bridge, opened, closes, events, update: (data: Update) => updates(data), close: (data: { instanceId: string }) => closed({ ...data, viewId: request.viewId, pluginId: 'com.example', reason: 'test' }) };
}
const request = { viewId: 'com.example.view', scopeId: 'window:one', context: {} };
test('a hidden view resumes its existing state and plugin instance', async () => {
const f = fixture(); const store = new PluginViewSessions();
const first = store.acquire('view', f.bridge, request); const id = f.opened[0].instanceId;
f.update({ kind: 'host-ui.update', viewId: request.viewId, instanceId: id, sequence: 1, css: '', mutations: [[0, '~', { id: 'text', type: 3, data: 'kept' }, 0]] });
first.release(); const next = store.acquire('view', f.bridge, request);
assert.equal(next, first); assert.equal(f.opened.length, 1); assert.deepEqual(next.getSnapshot().tree.children[0], { id: 'text', type: 3, data: 'kept' });
next.release(); store.dispose(); await Promise.resolve(); assert.equal(new Set(f.closes).size, 1);
});
test('host invalidation clears retained content and a reopen creates a new instance', () => {
const f = fixture(); const store = new PluginViewSessions(); const old = store.acquire('view', f.bridge, request);
f.close({ instanceId: f.opened[0].instanceId }); assert.equal(old.getSnapshot().error, true); assert.deepEqual(old.getSnapshot().tree.children, []);
const next = store.acquire('view', f.bridge, request); assert.notEqual(old, next); store.dispose();
});
test('unacknowledged input is not overwritten by an older plugin update', () => {
const f = fixture(); const store = new PluginViewSessions(); const view = store.acquire('view', f.bridge, request);
view.send({ type: 'input', nodeId: 'field', value: 'latest' }); assert.equal(view.getSnapshot().settled, false);
const base = { kind: 'host-ui.update' as const, viewId: request.viewId, instanceId: f.opened[0].instanceId, css: '', mutations: [] };
f.update({ ...base, sequence: 1, acknowledgedEvent: 0 }); assert.equal(view.getSnapshot().settled, false);
f.update({ ...base, sequence: 2, acknowledgedEvent: 1 }); assert.equal(view.getSnapshot().settled, true); store.dispose();
});
test('idle instances are evicted while active instances are never silently replaced', () => {
const f = fixture(); const store = new PluginViewSessions(1); const first = store.acquire('one', f.bridge, request);
assert.throws(() => store.acquire('two', f.bridge, request), /Too many/); first.release();
const second = store.acquire('two', f.bridge, request); assert.notEqual(second, first); assert.equal(f.closes.length, 1); store.dispose();
});
+78
View File
@@ -0,0 +1,78 @@
import { createPluginRemoteTree } from './pluginRemoteTree';
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
type Bridge = Pick<NonNullable<ReturnType<typeof import('../../infrastructure/services/netcattyBridge').netcattyBridge.get>>, 'openHostDrawnPluginView' | 'closeHostDrawnPluginView' | 'onHostDrawnPluginViewUpdate' | 'onPluginViewClosed' | 'sendHostDrawnPluginViewEvent' | 'getPluginViewResource'>;
export type PluginViewSnapshot = { tree: ReturnType<ReturnType<typeof createPluginRemoteTree>['snapshot']>; css: string; loading: boolean; error: boolean; settled: boolean };
const empty = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
/** Owns a window's bounded, retained UI instances independently of React mounts. */
export class PluginViewSessions {
private sessions = new Map<string, ReturnType<PluginViewSessions['create']>>();
constructor(private readonly capacity = 16, private readonly idleMs = 300_000) {}
private create(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
const id = crypto.randomUUID();
const tree = createPluginRemoteTree();
let state = empty(); let sequence = 1; let eventId = 0; let disposed = false; let users = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const listeners = new Set<() => void>();
const publish = (next: PluginViewSnapshot) => { state = next; for (const listener of listeners) listener(); };
const cleanup: Array<() => void> = [];
const dispose = (failed = false) => {
if (disposed) return;
disposed = true; clearTimeout(timer); cleanup.forEach((stop) => stop());
this.sessions.delete(key);
publish({ ...empty(), loading: false, error: failed });
void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
};
if (!bridge.openHostDrawnPluginView || !bridge.onHostDrawnPluginViewUpdate || !bridge.onPluginViewClosed) throw new Error('Plugin views unavailable');
cleanup.push(bridge.onHostDrawnPluginViewUpdate((update) => {
if (disposed || update.kind !== 'host-ui.update' || update.instanceId !== id || update.viewId !== request.viewId) return;
try {
if (update.sequence !== sequence++) throw new Error('Invalid plugin update order');
publish({ tree: tree.apply(update.mutations), css: validatePluginStylesheet(update.css), loading: false, error: false, settled: (update.acknowledgedEvent ?? 0) >= eventId });
} catch { dispose(true); }
}));
cleanup.push(bridge.onPluginViewClosed((event) => { if (event.instanceId === id) dispose(true); }));
void bridge.openHostDrawnPluginView({ ...request, instanceId: id }).then(() => {
if (disposed) void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
}).catch(() => dispose(true));
return {
viewId: request.viewId,
scope: request.scopeId,
image: async (path: string) => {
if (disposed || !bridge.getPluginViewResource) throw new Error('Plugin image unavailable');
const result = await bridge.getPluginViewResource({ instanceId: id, path });
if (disposed) throw new Error('Plugin view closed');
return result.url;
},
get idle() { return users === 0; },
getSnapshot: () => state,
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
acquire: () => { clearTimeout(timer); users++; },
release: () => { if (--users === 0 && !disposed) timer = setTimeout(() => dispose(), this.idleMs); },
send: (event: HostDrawnViewEvent) => {
if (disposed || users === 0) return;
publish({ ...state, settled: false });
void bridge.sendHostDrawnPluginViewEvent?.(id, { ...event, eventId: ++eventId }).catch(() => dispose(true));
},
dispose,
};
}
acquire(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
let session = this.sessions.get(key);
if (!session) {
while (this.sessions.size >= this.capacity) {
const candidate = [...this.sessions.values()].find((item) => item.idle);
if (!candidate) throw new Error('Too many open plugin views');
candidate.dispose();
}
session = this.create(key, bridge, request); this.sessions.set(key, session);
}
session.acquire(); return session;
}
closeView(viewId: string) { for (const session of [...this.sessions.values()]) if (session.viewId === viewId) session.dispose(); }
closeScope(scope: string) { for (const session of [...this.sessions.values()]) if (session.scope === scope) session.dispose(); }
dispose() { for (const session of [...this.sessions.values()]) session.dispose(); }
}
export const pluginViewSessions = new PluginViewSessions();
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('plugin tools stay in the toolbar and retain placement across catalog changes and remount', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'localStorage', 'StorageEvent', 'CustomEvent', 'Event']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { useTerminalSidePanelTabOrder, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER } = await import('./terminalSidePanelTabs');
const { STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT } = await import('../../infrastructure/config/storageKeys');
const plugin = 'plugin-view:com.example.sidebar.page' as const;
dom.window.localStorage.setItem(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT, JSON.stringify({ order: TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, placement: {} }));
let model: ReturnType<typeof useTerminalSidePanelTabOrder>;
let tools: typeof plugin[] = [];
function Fixture() { model = useTerminalSidePanelTabOrder(tools); return <div />; }
const root = createRoot(document.getElementById('root')!);
const render = async () => { await act(async () => { root.render(<Fixture />); }); };
try {
await render();
tools = [plugin]; await render();
assert.equal(model!.sidePanelTabOrder[1], plugin, 'new plugin is a visible toolbar item');
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).shown.includes(plugin));
await act(async () => { model!.setPlacement(plugin, 'collapse', [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]); });
tools = []; await render();
assert.ok(!model!.partition(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER).collapsed.includes(plugin));
tools = [plugin]; await render();
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin));
await act(async () => { root.unmount(); });
const nextRoot = createRoot(document.getElementById('root')!);
tools = []; await act(async () => { nextRoot.render(<Fixture />); });
tools = [plugin]; await act(async () => { nextRoot.render(<Fixture />); });
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin), 'cold catalog loading preserves preference');
await act(async () => { nextRoot.unmount(); });
} finally {
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
+40 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
normalizeToolbarItemLayout,
@@ -13,7 +13,7 @@ import {
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout';
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
import { isPluginSidePanelTool, type BuiltinSidePanelTool, type SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId = SidePanelTool;
@@ -101,7 +101,7 @@ function migrateSidePanelTabLayoutIfNeeded(): void {
migrateSidePanelTabLayoutIfNeeded();
export function useTerminalSidePanelTabOrder(): {
export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool[] = []): {
sidePanelTabOrder: TerminalSidePanelTabId[];
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
layout: ToolbarItemLayout;
@@ -111,6 +111,23 @@ export function useTerminalSidePanelTabOrder(): {
resetLayout: () => void;
partition: ReturnType<typeof useToolbarItemLayout>['partition'];
} {
// Keep preferences for temporarily unavailable/disabled plugins, including
// across a cold start before the contribution catalog finishes loading.
const knownPluginTools = useRef<Set<string> | null>(null);
if (!knownPluginTools.current) {
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { stored = null; }
const order = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : [];
knownPluginTools.current = new Set(Array.isArray(order)
? order.filter((id): id is string => typeof id === 'string' && isPluginSidePanelTool(id)) : []);
}
for (const tool of pluginTools) if (isPluginSidePanelTool(tool)) knownPluginTools.current.add(tool);
const pluginKey = [...knownPluginTools.current].join('\0');
const defaults = useMemo<ToolbarItemLayoutDefaults>(() => {
const plugins = pluginKey ? pluginKey.split('\0') : [];
return { order: [TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER[0], ...plugins, ...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.slice(1)] };
}, [pluginKey]);
const {
layout,
setPlacement,
@@ -121,9 +138,28 @@ export function useTerminalSidePanelTabOrder(): {
partition,
} = useToolbarItemLayout(
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS,
defaults,
);
// Newly installed plugins get a visible slot near the start even when an
// older saved layout already exists. Existing choices always keep their order.
const activePluginKey = pluginTools.filter(isPluginSidePanelTool).join('\0');
useEffect(() => {
const active = activePluginKey ? activePluginKey.split('\0') : [];
if (!active.length || active.some((id) => !layout.order.includes(id))) return;
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { return; }
const storedOrder = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : null;
const saved = new Set(Array.isArray(storedOrder) ? storedOrder : []);
const fresh = active.filter((id) => !saved.has(id));
if (!fresh.length) return;
const rest = layout.order.filter((id) => !fresh.includes(id));
const index = Math.max(0, rest.indexOf('sftp') + 1);
const next = [...rest.slice(0, index), ...fresh, ...rest.slice(index)];
if (next.some((id, i) => id !== layout.order[i])) setOrder(next);
}, [activePluginKey, layout.order, setOrder]);
const sidePanelTabOrder = layout.order as TerminalSidePanelTabId[];
const setSidePanelTabOrder = useCallback(
-119
View File
@@ -1,119 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { isPluginViewOccluded } from './pluginViewOcclusion';
import type { UsePluginContributionsResult } from './usePluginContributions';
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
viewId: string;
version: string;
slot: string;
active: boolean;
context: Record<string, unknown>;
adapter: ViewAdapter;
subscribeHidden(listener: () => void): () => void;
unavailable(): void;
focused?(): void;
focusOnReveal?: boolean;
}) {
const mountRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const adapterRef = useRef(adapter);
const unavailableRef = useRef(unavailable);
const focusedRef = useRef(focused);
const revealFocusRef = useRef(focusOnReveal);
const requestFocusRef = useRef<() => void>(() => {});
revealFocusRef.current = focusOnReveal;
focusedRef.current = focused;
adapterRef.current = adapter;
unavailableRef.current = unavailable;
useEffect(() => {
const mount = mountRef.current;
if (!active || !mount) return;
let cancelled = false;
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
const instanceId = crypto.randomUUID();
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
const close = () => {
if (cancelled) return;
cancelled = true;
lifecycle.markViewClosed(viewId);
void closeView(instanceId).catch(() => {});
};
const failCurrent = () => {
if (cancelled) return;
close();
unavailableRef.current();
};
const unsubscribe = onViewClosed((event) => {
lifecycle.handleHostClose(event.instanceId);
if (event.instanceId === instanceId && !cancelled) {
close();
unavailableRef.current();
}
});
const stopOnHide = subscribeHidden(close);
const stopOnFocus = onViewFocused((event) => {
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
});
const bounds = () => {
const rect = mount.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
};
let opened = false;
let lastBounds = '';
let visible = false;
const focus = () => {
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
};
requestFocusRef.current = focus;
const updateVisibility = () => {
if (!opened || cancelled) return;
const rect = mount.getBoundingClientRect();
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
if (visible === next) return;
visible = next;
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
};
const resize = () => {
if (opened && !cancelled) {
const next = bounds();
const key = JSON.stringify(next);
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
}
updateVisibility();
};
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
observer?.observe(mount);
const mutations = new MutationObserver(resize);
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
window.addEventListener('resize', resize);
document.addEventListener('scroll', resize, true);
setLoading(true);
const opening = lifecycle.beginOpen({ viewKey: viewId });
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
await closeView(result.instanceId);
return;
}
opened = true;
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
resize();
setLoading(false);
}).catch(() => {
if (!cancelled) { close(); unavailableRef.current(); }
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
return () => {
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
};
}, [active, context, slot, subscribeHidden, version, viewId]);
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
return { mountRef, loading };
}
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { pluginViewSessions, type PluginViewSnapshot } from './pluginViewSession';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
const initial = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
export function useHostDrawnPluginView({ viewId, version, slot, active, context, subscribeHidden, unavailable }: {
viewId: string; version: string; slot: string; active: boolean; context: Record<string, unknown>;
subscribeHidden(listener: () => void): () => void; unavailable(): void;
}) {
const contextKey = JSON.stringify(context);
const key = JSON.stringify([viewId, version, slot, contextKey]);
const [state, setState] = useState<{ key: string; snapshot: PluginViewSnapshot }>({ key: '', snapshot: initial() });
const [hidden, setHidden] = useState(false);
const unavailableRef = useRef(unavailable); unavailableRef.current = unavailable;
const sessionRef = useRef<{ key: string; image(path: string): Promise<string>; send(event: HostDrawnViewEvent): void } | null>(null);
useEffect(() => {
if (!active) return;
const bridge = netcattyBridge.get();
if (!bridge) { unavailableRef.current(); return; }
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname, search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
try {
const session = pluginViewSessions.acquire(JSON.stringify([scopeId, key]), bridge, { viewId, scopeId, context: JSON.parse(contextKey) });
const update = () => { const snapshot = session.getSnapshot(); setState({ key, snapshot }); if (snapshot.error) unavailableRef.current(); };
const stop = session.subscribe(update); update(); setHidden(false);
sessionRef.current = { key, send: session.send, image: session.image };
const stopHidden = subscribeHidden(() => { sessionRef.current = null; setHidden(true); });
return () => { sessionRef.current = null; stopHidden(); stop(); session.release(); };
} catch { unavailableRef.current(); }
}, [active, contextKey, key, slot, subscribeHidden, viewId]);
const loadImage = useCallback((path: string) => {
const session = sessionRef.current;
return active && session?.key === key ? session.image(path) : Promise.reject(new Error('Plugin view unavailable'));
}, [active, key]);
const sendEvent = useCallback((event: HostDrawnViewEvent) => {
const session = sessionRef.current; if (active && session?.key === key) session.send(event);
}, [active, key]);
// A newly selected plugin must never receive values from the previous tree,
// even during the render before effect cleanup and the next activation.
return { ...(state.key === key ? state.snapshot : initial()), visible: active && !hidden, loadImage, sendEvent };
}
+17
View File
@@ -0,0 +1,17 @@
import { useState } from "react";
import { buildMonitorDiskListLayout, type MonitorDiskListStats } from "../../domain/monitorDisks";
/** Volatile per-host disclosure preference; capacity alerts always force it open. */
export function useMonitorDiskGroups() {
const [expandedHosts, setExpandedHosts] = useState<ReadonlySet<string>>(() => new Set());
return {
layoutFor: (hostId: string, stats?: MonitorDiskListStats | null) =>
buildMonitorDiskListLayout(stats, expandedHosts.has(hostId)),
toggle: (hostId: string) => setExpandedHosts(previous => {
const next = new Set(previous);
if (next.has(hostId)) next.delete(hostId);
else next.add(hostId);
return next;
}),
};
}
+11
View File
@@ -10,6 +10,7 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
const [listAttempt, setListAttempt] = useState(0);
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [imageState, setImageState] = useState<{ id: string; token?: string; sourceId: string; images: Record<string, string> } | null>(null);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
@@ -51,9 +52,19 @@ export function usePluginCatalog(selectedId: string | null, sourceId = 'official
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
useEffect(() => {
let active = true;
if (selectedId && selectionToken) {
void netcattyBridge.get()?.getPluginCatalogReadmeImages?.(selectedId, selectionToken, sourceId)
.then((images) => { if (active) setImageState({ id: selectedId, token: selectionToken, sourceId, images }); })
.catch(() => { if (active) setImageState(null); });
}
return () => { active = false; };
}, [selectedId, selectionToken, sourceId, readmeAttempt]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source, sourceBinding,
readmeImages: imageState?.id === selectedId && imageState.token === selectionToken && imageState.sourceId === sourceId ? imageState.images : {},
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
@@ -86,14 +86,8 @@ export interface UsePluginContributionsResult {
updateSetting(pluginId: string, settingId: string, value: unknown, scopeId?: string): Promise<{ restartRequired: boolean }>;
resetSetting(pluginId: string, settingId: string, scopeId?: string): Promise<{ restartRequired: boolean }>;
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
closeView(instanceId: string): Promise<void>;
focusView(instanceId: string): Promise<void>;
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
}
export function usePluginContributions(
@@ -189,32 +183,6 @@ export function usePluginContributions(
return picker(title, defaultPath);
}, [bridge]);
const openView = useCallback(async (payload: NetcattyPluginViewOpenRequest) => {
if (!bridge?.openPluginView) throw new Error('Plugin views are unavailable');
return bridge.openPluginView(payload);
}, [bridge]);
const closeView = useCallback(async (instanceId: string) => {
if (!bridge?.closePluginView) return;
await bridge.closePluginView(instanceId);
}, [bridge]);
const focusView = useCallback(async (instanceId: string) => {
await bridge?.focusPluginView?.({ instanceId });
}, [bridge]);
const setViewBounds = useCallback(async (
instanceId: string,
bounds: { x: number; y: number; width: number; height: number },
) => {
if (!bridge?.setPluginViewBounds) return;
await bridge.setPluginViewBounds(instanceId, bounds);
}, [bridge]);
const setViewVisibility = useCallback(async (instanceId: string, visible: boolean) => {
if (!bridge?.setPluginViewVisibility) return;
await bridge.setPluginViewVisibility(instanceId, visible);
}, [bridge]);
const setEnvironment = useCallback(async (environment: NetcattyPluginEnvironment) => {
if (!bridge?.setPluginEnvironment) return;
await bridge.setPluginEnvironment(environment);
@@ -223,10 +191,6 @@ export function usePluginContributions(
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
), [bridge]);
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
), [bridge]);
const currentLoadState = resolvePluginContributionLoadState({
currentQueryKey: queryKey,
loadedQueryKey: loadedSnapshot.queryKey,
@@ -245,13 +209,7 @@ export function usePluginContributions(
updateSetting,
resetSetting,
selectSettingPath,
openView,
closeView,
focusView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
onViewFocused,
};
}
+6 -4
View File
@@ -38,18 +38,20 @@ export function usePluginManager() {
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>, throwOnError = false) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
let failure: Error | null = null;
try { await action(netcattyBridge.require()); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
catch (cause) { failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
finally {
try { await refresh(); } catch (cause) { setError(String(cause)); }
try { await refresh(); } catch (cause) { failure ??= cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
mutation.current = false;
setBusy(false);
}
if (throwOnError && failure) throw failure;
}, [refresh]);
return {
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
@@ -70,6 +72,6 @@ export function usePluginManager() {
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
await bridge.resetPluginSource(id, source.id);
}
}),
}, true),
};
}
@@ -0,0 +1,22 @@
import { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { usePluginContributions } from './usePluginContributions';
import { resolvePluginSettingScopeSelection, usePluginSettingScopeCatalog } from './usePluginSettingScopeCatalog';
export function usePluginSettingsModel(pluginId: string) {
const { locale } = useI18n();
const catalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
useEffect(() => {
setScopeIds(current => resolvePluginSettingScopeSelection(catalog, current));
}, [catalog]);
const query = useMemo<NetcattyPluginContributionQuery>(() => ({
locale, context: { 'netcatty.surface': 'settings' }, scopeIds,
}), [locale, scopeIds]);
const contributions = usePluginContributions(query);
const plugin = contributions.snapshot.plugins.find(item => item.id === pluginId);
const settings = plugin?.settings.filter(setting => setting.visible) ?? [];
const contextualScopes = [...new Set(settings.map(setting => setting.scope)
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application'))];
return { ...contributions, plugin, settings, catalog, scopeIds, setScopeIds, contextualScopes };
}
+9 -209
View File
@@ -14,19 +14,12 @@ import {
usePluginViewTabs,
} from './pluginViewTabStore';
import {
PluginViewLifecycleController,
reconcilePluginViewTabCatalog,
resolvePluginViewSnapshotSelection,
type HostedPluginViewState,
type PluginViewSnapshotSelection,
} from './pluginViewLifecycle';
import {
canRetainPluginViewInScope,
resolvePluginRetainedViewKey,
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { pluginViewSessions } from './pluginViewSession';
import { usePluginContributions } from './usePluginContributions';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -70,17 +63,8 @@ export function usePluginViewLifecycle({
const {
snapshot,
executeCommand,
openView,
closeView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
} = contributions;
const [instance, setInstance] = useState<HostedPluginViewState | null>(null);
const lifecycleRef = useRef(new PluginViewLifecycleController<HostedPluginViewState>());
const closeViewRef = useRef(closeView);
const mountRef = useRef<HTMLDivElement>(null);
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
@@ -93,16 +77,6 @@ export function usePluginViewLifecycle({
requestedViewId: effectiveRequested?.viewId,
contextKey: viewQueryContextKey,
});
const activeViewId = activeView?.view.id;
const viewScopeId = typeof window === 'undefined'
? 'window:server'
: resolvePluginViewWindowScope(window.location);
const retainedViewKey = activeViewId
? resolvePluginRetainedViewKey(activeViewId, viewScopeId)
: null;
useEffect(() => { closeViewRef.current = closeView; }, [closeView]);
useEffect(() => {
if (viewContributions.loading) return;
stableActiveViewRef.current = resolvedActiveView && effectiveRequested?.viewId
@@ -114,26 +88,6 @@ export function usePluginViewLifecycle({
: null;
}, [effectiveRequested?.viewId, resolvedActiveView, viewContributions.loading, viewQueryContextKey]);
useEffect(() => onViewClosed((event) => {
const next = lifecycleRef.current.handleHostClose(event.instanceId);
if (next.matchedCurrent) {
setInstance(null);
setRequested(null);
}
if (next.closedTabId) pluginViewTabStore.close(next.closedTabId);
}), [onViewClosed]);
useEffect(() => pluginViewTabStore.onDidClose(({ tab }) => {
const next = lifecycleRef.current.handleTabClose(tab.id);
if (next.matchedCurrent) {
setInstance(null);
setRequested(null);
}
for (const instanceId of next.instanceIds) {
void closeViewRef.current(instanceId).catch(() => {});
}
}), []);
useEffect(() => {
const bindings = snapshot.plugins.flatMap((plugin) => plugin.keybindings)
.filter((binding) => binding.enabled)
@@ -182,164 +136,16 @@ export function usePluginViewLifecycle({
setRequested(null);
}, [activeView, requested]);
const hideOrClose = useCallback(async (current: HostedPluginViewState) => {
if (!current.retainContextWhenHidden) {
await closeView(current.id);
return;
}
const key = resolvePluginRetainedViewKey(current.viewId, current.scopeId);
lifecycleRef.current.retain(key, current);
try {
await setViewVisibility(current.id, false);
} catch {
lifecycleRef.current.removeRetained(key);
await closeView(current.id);
}
}, [closeView, setViewVisibility]);
useEffect(() => {
const stop = pluginViewTabStore.onDidClose(({ tab }) => pluginViewSessions.closeView(tab.viewId));
return () => { stop(); };
}, []);
const close = useCallback(async () => {
lifecycleRef.current.markViewClosed(retainedViewKey);
const current = lifecycleRef.current.takeCurrent();
setInstance(null);
setRequested(null);
if (current) await closeView(current.id);
}, [closeView, retainedViewKey]);
useEffect(() => {
const removed = lifecycleRef.current.removeRetainedWhere(
(retained) => !canRetainPluginViewInScope(retained.scopeId, viewScopeId),
);
for (const retained of removed) void closeView(retained.id).catch(() => {});
}, [closeView, viewScopeId]);
useEffect(() => {
if (!instance) return;
if (effectiveRequested?.viewId === instance.viewId
&& activeViewId === instance.viewId
&& instance.scopeId === viewScopeId) return;
lifecycleRef.current.takeCurrent();
setInstance(null);
if (canRetainPluginViewInScope(instance.scopeId, viewScopeId)) {
void hideOrClose(instance);
} else {
void closeView(instance.id).catch(() => {});
}
}, [activeViewId, closeView, effectiveRequested?.viewId, hideOrClose, instance, viewScopeId]);
useEffect(() => {
if (!activeViewId || !retainedViewKey || !mountRef.current || instance) return;
let cancelled = false;
const openingTabId = activePluginTab?.id;
const openingToken = lifecycleRef.current.beginOpen({
viewKey: retainedViewKey,
tabId: openingTabId,
label: activePluginTab?.id ?? activeViewId,
});
const bounds = mountRef.current.getBoundingClientRect();
const nextBounds = {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
};
void (async () => {
let opened = lifecycleRef.current.takeRetained(retainedViewKey);
if (opened) {
try {
await setViewBounds(opened.id, nextBounds);
await setViewVisibility(opened.id, true);
} catch {
try { await closeView(opened.id); } catch { /* best-effort cleanup after restore failure */ }
opened = null;
}
}
if (!opened) {
const result = await openView({
viewId: activeViewId,
scopeId: viewScopeId,
bounds: nextBounds,
context: effectiveRequested?.context,
});
opened = {
id: result.instanceId,
viewId: activeViewId,
scopeId: viewScopeId,
retainContextWhenHidden: activeView.view.retainContextWhenHidden === true,
...(activePluginTab ? { tabId: activePluginTab.id } : {}),
};
}
if (lifecycleRef.current.consumeHostClose(opened.id)) {
throw new Error('Plugin view closed while its open response was in flight');
}
if (lifecycleRef.current.shouldCloseOpen(openingToken)) {
await closeView(opened.id);
return;
}
if (cancelled) {
await hideOrClose(opened);
return;
}
lifecycleRef.current.setCurrent(opened);
setInstance(opened);
})().catch(() => {
if (cancelled) return;
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
else setRequested(null);
}).finally(() => {
lifecycleRef.current.finishOpen({
token: openingToken,
viewKey: retainedViewKey,
tabId: openingTabId,
});
});
return () => { cancelled = true; };
}, [
activePluginTab,
activeView?.view.retainContextWhenHidden,
activeViewId,
closeView,
effectiveRequested?.context,
hideOrClose,
instance,
openView,
retainedViewKey,
setViewBounds,
setViewVisibility,
viewScopeId,
]);
useEffect(() => {
if (!instance || !mountRef.current) return;
let disposed = false;
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
void setViewVisibility(instance.id, visible).catch(() => {
if (!disposed) void closeView(instance.id).catch(() => {});
});
});
return () => { disposed = true; stop(); };
}, [closeView, instance, setViewVisibility]);
useEffect(() => {
if (!instance || !mountRef.current) return;
const update = () => {
const bounds = mountRef.current?.getBoundingClientRect();
if (!bounds) return;
void setViewBounds(instance.id, {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
});
};
const observer = new ResizeObserver(update);
observer.observe(mountRef.current);
window.addEventListener('resize', update);
update();
return () => {
observer.disconnect();
window.removeEventListener('resize', update);
};
}, [instance, setViewBounds]);
if (activeView) pluginViewSessions.closeView(activeView.view.id);
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
}, [activePluginTab, activeView]);
useEffect(() => {
if (!contributions.available) return;
@@ -376,16 +182,10 @@ export function usePluginViewLifecycle({
};
}, [contributions.available, locale, setEnvironment, suppliedThemeTokens, theme]);
useEffect(() => () => {
const views = lifecycleRef.current.drain();
const ids = new Set(views.map((view) => view.id));
for (const id of ids) void closeViewRef.current(id);
}, []);
return {
activeView,
close,
effectiveRequested,
mountRef,
viewQueryContext,
};
}
+2
View File
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export interface DiskInfo {
diskReadSpeed?: number | null; // Mounted block-device I/O, bytes/sec
diskWriteSpeed?: number | null;
capacityKey?: string; // Filesystem or shared-pool identity for deduplication
filesystemType?: string; // Filesystem type reported by df (for network/FUSE filtering)
mountPoint: string;
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useToolAccessGuidanceState, type ToolAccessGuidanceState } from './useToolAccessGuidanceState';
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
async function harness() {
const requests: ReturnType<typeof deferred<unknown>>[] = [];
const events = new EventTarget();
const values = new Map([[STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true']]);
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = {
IS_REACT_ACT_ENVIRONMENT: true,
localStorage: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) },
window: {
netcatty: {
externalMcpGetStatus: () => { const request = deferred<unknown>(); requests.push(request); return request.promise; },
aiSkillsCliGetInvocation: async () => ({ ok: true, skillPath: '/fixture/skills/SKILL.md', commandPrefix: 'fixture-cli' }),
},
addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events), setTimeout, clearTimeout,
},
};
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
}
let result!: ToolAccessGuidanceState;
function Harness({ mode }: { mode: AIToolIntegrationMode }) { result = useToolAccessGuidanceState(mode); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(<Harness mode="mcp" />); });
return {
requests, events, values, result: () => result,
skills: async () => { await act(async () => renderer.update(<Harness mode="skills" />)); },
close: async () => {
await act(async () => renderer.unmount());
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
},
};
}
const status = (path: string) => ({ ok: true, enabled: true, state: 'running', launcherPath: path, discoveryPath: `${path}.json` });
test('older MCP guidance response cannot overwrite a newer enable-state refresh', async () => {
const h = await harness();
try {
assert.equal(h.requests.length, 1);
await act(async () => { h.events.dispatchEvent(new CustomEvent(AI_STATE_CHANGED_EVENT, { detail: { key: STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } })); });
assert.equal(h.requests.length, 2);
await act(async () => h.requests[1].resolve(status('/fixture/new')));
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
await act(async () => h.requests[0].resolve(status('/fixture/old')));
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
} finally { await h.close(); }
});
test('switching to Skills ignores pending MCP results and preserves resolved Skills guidance', async () => {
const h = await harness();
try {
await h.skills();
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
assert.equal(h.result().mcpLauncherPath, null);
assert.equal(h.result().skillPath, '/fixture/skills/SKILL.md');
assert.equal(h.result().commandPrefix, 'fixture-cli');
} finally { await h.close(); }
});
test('MCP guidance completion after unmount has no visible state or retry work', async () => {
const h = await harness();
const initial = h.result();
await h.close();
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
assert.equal(h.result(), initial);
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -38,14 +38,15 @@ export type ToolAccessGuidanceState = {
* runtime is still starting or after an idle shutdown).
*/
export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAccessGuidanceState {
const requestGeneration = useRef(0);
const [skillPath, setSkillPath] = useState<string | null>(null);
const [commandPrefix, setCommandPrefix] = useState('');
const [mcpLauncherPath, setMcpLauncherPath] = useState<string | null>(null);
const [mcpDiscoveryPath, setMcpDiscoveryPath] = useState<string | null>(null);
const refreshMcpStatus = useCallback(async (): Promise<string | null | undefined> => {
const refreshMcpStatus = useCallback(async (isCurrent: () => boolean): Promise<string | null | undefined> => {
const status = await netcattyBridge.get()?.externalMcpGetStatus?.();
if (!status?.ok) return undefined;
if (!isCurrent() || !status?.ok) return undefined;
// Do not trust `enabled` alone: the main process flips `enabled` before
// host credentials are resolved and the discovery file is written, and it
// keeps reporting the configured discovery/launcher paths while starting.
@@ -93,9 +94,11 @@ export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAcc
const refetch = async () => {
if (cancelled) return;
const generation = ++requestGeneration.current;
const isCurrent = () => !cancelled && generation === requestGeneration.current;
const storedEnabled = readExternalMcpStoredEnabled();
const readyLauncherPath = await refreshMcpStatus().catch(() => undefined);
if (cancelled) return;
const readyLauncherPath = await refreshMcpStatus(isCurrent).catch(() => undefined);
if (!isCurrent()) return;
// The persisted switch is off but the status check failed: drop any
// cached launcher/discovery paths. The stored credentials were revoked
// by the disable, and falling through to the comparison below would see
@@ -63,6 +63,9 @@ export function useToolbarItemLayout(
const [layout, setLayout] = useState<ToolbarItemLayout>(() => readLayout(storageKey, defaults));
useEffect(() => {
// Defaults can include dynamically discovered items; re-read without writing
// so temporary catalog changes never erase stored toolbar preferences.
setLayout(readLayout(storageKey, defaults));
const sync = (event: Event) => {
if (event instanceof StorageEvent && event.key !== storageKey) return;
if (event instanceof CustomEvent && event.detail?.key !== storageKey) return;
+42 -2
View File
@@ -208,13 +208,15 @@ test('replacement stops active turns and waits for finalizers before persisting
globalThis.localStorage.setItem = (key, value) => { if (key === keys.STORAGE_KEY_AI_SESSIONS) order.push('persist'); original(key, value); };
const incoming = { ...session(), updatedAt: 3, messages: [...session().messages,
{ id: 'remote', role: 'user' as const, content: 'Remote', timestamp: 3 }] };
await applySyncPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined });
await applyLocalVaultPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined }, {
prepareConvergentRestore: async () => async () => {},
});
assert.ok(order.indexOf('cancel') < order.indexOf('settled'));
assert.ok(order.indexOf('settled') < order.indexOf('persist'));
assert.ok(order.indexOf('persist') < order.indexOf('cleanup'));
assert.equal(hook!.abortControllersRef.current.has('chat'), false);
if (remove) assert.deepEqual(getAISessionsSnapshot().sessions, []);
else assert.ok(getAISessionsSnapshot().sessions[0].messages.some(message => message.content === 'Settled'));
else assert.equal(getAISessionsSnapshot().sessions[0].messages.at(-1)?.content, 'Remote');
wait.mock.restore();
globalThis.localStorage.setItem = original;
await act(async () => { hook!.setStreamingForScope('chat', false); });
@@ -226,6 +228,44 @@ test('replacement stops active turns and waits for finalizers before persisting
}
});
test('cloud sync preserves an active trimmed conversation and its native thread', async () => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
let hook: UseAIChatStreamingReturn;
function Harness() {
hook = useAIChatStreaming({ maxIterations: 1, addMessageToSession: () => {}, updateLastMessage: () => {}, updateMessageById: () => {} });
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Harness)); });
const calls: string[] = [];
const controller = new AbortController();
const messages = Array.from({ length: 501 }, (_, i) => ({ id: `m-${i}`, role: 'user' as const, content: `Message ${i}`, timestamp: i }));
const remote = { ...session(), agentId: 'codex', messages };
// Appending the 501st message trims the oldest message locally.
const local = { ...remote, messages: messages.slice(-500), externalSessionId: 'native-thread' };
save([local]);
hook!.abortControllersRef.current.set('chat', controller);
await act(async () => { hook!.setStreamingForScope('chat', true); });
Object.assign(window, { netcatty: {
aiCattyCancelExec: async () => { calls.push('cancel'); },
aiSdkAgentCancel: async () => { calls.push('sdk-cancel'); return { ok: true }; },
aiSdkAgentCleanup: async () => { calls.push('cleanup'); return { ok: true }; },
} });
try {
for (const incoming of [[remote], []]) {
await applySyncPayload(remotePayload(incoming), { importVaultData: () => undefined });
assert.equal(controller.signal.aborted, false);
assert.deepEqual(calls, []);
assert.deepEqual(getAISessionsSnapshot().sessions, [local]);
assert.equal(JSON.parse(storage.get(keys.STORAGE_KEY_AI_SESSIONS)!)[0].externalSessionId, 'native-thread');
}
} finally {
hook!.abortControllersRef.current.delete('chat');
await act(async () => { hook!.setStreamingForScope('chat', false); renderer!.unmount(); });
delete (window as unknown as { netcatty?: unknown }).netcatty;
}
});
test('local restore replaces later sessions and later turns with the backup snapshot', async () => {
const backedUp = session();
const later = { ...session(), updatedAt: 3, messages: [...session().messages,
+8 -3
View File
@@ -1275,6 +1275,8 @@ export async function buildLocalVaultPayloadAsync(
/** Merge downloaded chat data through the existing state and persistence boundary. */
async function applyAiSessionData(sessions: NonNullable<SyncPayload['aiSessions']>, includeLocalOnlyData: boolean): Promise<void> {
const { isAIChatSessionStreaming, stopAIChatSessionBeforeReplacement } = await import('./state/useAIChatStreaming');
const preserveActive = (id: string) => !includeLocalOnlyData && isAIChatSessionStreaming(id);
const incoming = includeLocalOnlyData
? sessions
: sessions.map(stripAiSessionDeviceState);
@@ -1282,26 +1284,29 @@ async function applyAiSessionData(sessions: NonNullable<SyncPayload['aiSessions'
const incomingById = new Map(incoming.map((session) => [session.id, session]));
const portableMessages = (value: typeof incoming[number]) => stripAiSessionPayloadForSync([value])[0].messages;
const affectedIds = collectLocalAiSessions().filter((local) => {
if (preserveActive(local.id)) return false;
const next = incomingById.get(local.id);
if (!next) return true;
const candidate = includeLocalOnlyData ? next : mergeAiSessionLists([local], [next])[0];
return JSON.stringify(portableMessages(local)) !== JSON.stringify(portableMessages(candidate));
}).map((session) => session.id);
if (affectedIds.length > 0) {
const { stopAIChatSessionBeforeReplacement } = await import('./state/useAIChatStreaming');
await Promise.all(affectedIds.map(stopAIChatSessionBeforeReplacement));
}
// Stop finalizers can finish the last local message. Read it again before
// merging so the stopped turn cannot write into a replaced/deleted session.
const localSessions = collectLocalAiSessions();
const localById = new Map(localSessions.map((session) => [session.id, session]));
const removedIds = localSessions.filter((session) => !incomingIds.has(session.id)).map((session) => session.id);
const removedIds = localSessions.filter((session) => !incomingIds.has(session.id) && !preserveActive(session.id)).map((session) => session.id);
const resetSdkSessions: string[] = [];
// The sync merge has already decided which session IDs survive. Backups
// restore their exact transcript; cloud survivors still retain local turns.
const candidates = includeLocalOnlyData ? incoming
: mergeAiSessionLists(localSessions.filter((session) => incomingIds.has(session.id)), incoming);
const mergedSessions = candidates.map((session) => {
// Keep active cloud sessions intact until a later sync, including remote deletions.
const retainedActive = localSessions.filter((session) => preserveActive(session.id));
const mergedSessions = [...candidates.filter((session) => !preserveActive(session.id)), ...retainedActive].map((session) => {
if (preserveActive(session.id)) return session;
// Native SDK threads belong to this device. Reuse the local thread
// only while it already contains the entire merged transcript.
const local = localById.get(session.id);
+7 -7
View File
@@ -147,8 +147,8 @@ test('cold plugin host reaches the local fixture through the actual main provide
await service.manager.initialize();
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
const sample = path.join(rootPath, 'examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
service.database.setPermissionMode('com.netcatty.assistant', 'context.selected.read', 'ask');
@@ -161,13 +161,13 @@ test('cold plugin host reaches the local fixture through the actual main provide
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
}
};
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: {
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
onDidReceiveMessage: () => ({ dispose() {} }),
}, views: { onDidReceiveMessage: () => ({ dispose() {} }) }
} });
diagnosticPhase = 'initial-selection';
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
assert.equal(noteChoice.options.length, 2);
@@ -209,7 +209,7 @@ test('cold plugin host reaches the local fixture through the actual main provide
console.error = originalConsoleError;
// JSDOM.close does not deliver the browser pagehide lifecycle. Invalidate
// the sample's pending callbacks before teardown closes its document.
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
if (panel) { panel.window.disposePanel(); panel.window.close(); }
await act(async () => root.unmount());
await service?.manager.shutdown(); aiBridge.cleanup();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
+8 -8
View File
@@ -90,8 +90,8 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli'); const archive = path.join(directory, 'global-agent.ncpkg'); await buildPluginPackage(staging, archive);
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAgentHost sessions={[
@@ -104,14 +104,14 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
} };
let viewOpen = true;
const mountPanel = async () => {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: { executeCommand: async (name: string, params: unknown) => {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: { executeCommand: async (name: string, params: unknown) => {
// PluginViewHost.close removes sender authority before WebContents
// closes. A pagehide RPC cannot be relied on to reach this relay.
if (!viewOpen) throw Error('Untrusted plugin view sender');
const result = structuredClone(await service!.contributionService.executeCommand(name, structuredClone(params))); pluginReplies.push(result); return result;
}, onDidReceiveMessage: () => ({ dispose() {} }), onDidChangeEnvironment: () => ({ dispose() {} }) } });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
} }, views: { onDidReceiveMessage: () => ({ dispose() {} }) } } });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
};
await mountPanel();
const element = (id: string) => panel!.window.document.getElementById(id)!;
@@ -135,7 +135,7 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
const beforeReopen = counts.requests;
viewOpen = false;
panel!.window.dispatchEvent(new panel!.window.Event('pagehide')); panel!.window.close();
panel!.window.disposePanel(); panel!.window.close();
viewOpen = true; await mountPanel();
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
(element('conversation') as HTMLSelectElement).value = reference.conversationRef;
@@ -182,7 +182,7 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
assert.deepEqual(errors, []);
} finally {
console.error = originalError;
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
if (panel) { panel.window.disposePanel(); panel.window.close(); }
await act(async () => root.unmount()); await service?.manager.shutdown();
mcp.setPermissionMode('confirm'); mcp.cleanup(); aiBridge.cleanup(); privacy.configure({ enabled: false }); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); dom.window.close();
+21 -1
View File
@@ -5,6 +5,27 @@ import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
// Test-only observer preserves the security-surface visibility assertions.
function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
return () => observer.disconnect();
}
test('registered Ask uses one in-app dialog; policy controls apply Allow and Deny with no hidden prompt', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
@@ -25,7 +46,6 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
const { observePluginHostSecurityVisibility } = await import('../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
@@ -69,8 +69,8 @@ test('registered selected-summary Ask is one host chooser; Allow still selects a
await service.manager.initialize();
const stage = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(stage, 'dist'), { recursive: true });
const sample = path.resolve('examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(stage, 'dist/index.js'));
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(stage, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(stage, archive); await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionHost /><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
+21 -35
View File
@@ -14,11 +14,12 @@ test('terminal extension pages use actual split panes, focus and session ownersh
install('PointerEvent', dom.window.MouseEvent);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
let rectX = 200;
const rectX = 200;
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
const { default: React, act, useCallback, useRef, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { TooltipProvider } = await import('./ui/tooltip');
const { activeTabStore } = await import('../application/state/activeTabStore');
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
@@ -30,22 +31,17 @@ test('terminal extension pages use actual split panes, focus and session ownersh
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
let selectedSession = 'synthetic-session-a';
const getSession = () => selectedSession;
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', visible: true };
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
const visible = new Map<string, boolean>();
const changed = new Set<() => void>();
const opens: NetcattyPluginViewOpenRequest[] = [];
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
onPluginViewClosed() { return () => {}; },
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); },
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
onPluginViewClosed() { return () => {}; }, onHostDrawnPluginViewUpdate() { return () => {}; },
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closeHostDrawnPluginView() {},
} });
let split!: () => void;
function Fixture() {
@@ -75,54 +71,44 @@ test('terminal extension pages use actual split panes, focus and session ownersh
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
if (!page || !target) return null;
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} showHeader={panes.length === 1} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
</PersistentSidePanelPortal>;
})}</>;
}
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
try {
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
await act(async () => { root.render(<I18nProvider locale="en"><TooltipProvider><Fixture /></TooltipProvider></I18nProvider>); await settle(); });
assert.equal(document.querySelector('button[aria-label="Extension pages"]'), null, 'pages do not require opening a grouped menu');
assert.ok(document.querySelector('button[aria-label="Second page — Synthetic plugin"]'), 'each contributed page has a direct toolbar button');
const firstButton = document.querySelector<HTMLButtonElement>('button[aria-label="First page — Synthetic plugin"]')!;
await act(async () => { firstButton.click(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 1);
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
await act(async () => { split(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 0, 'split pane headers already identify both pages');
assert.equal(opens.length, 2);
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
await act(async () => { document.querySelector<HTMLElement>(`[data-pane-tool="plugin-view:${a.id}"] [data-plugin-view-slot]`)!.dispatchEvent(new dom.window.FocusEvent('focusin', { bubbles: true })); await settle(); });
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
focus.length = 0;
await act(async () => { dialog.remove(); await settle(); });
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
const old = opens.map((item) => item.instanceId!);
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession }); await settle(); });
assert.equal(opens.length, 4);
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
const last = opens.at(-1)!.instanceId!;
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
const localPage = document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!;
await act(async () => { localPage.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+14 -37
View File
@@ -21,24 +21,19 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
const { activeTabStore } = await import('../application/state/activeTabStore');
const { requestOpenPluginView } = await import('../application/state/usePluginViewLifecycle');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', visible: true };
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', visible: true };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] };
let changed: (() => void) | undefined;
const listeners = new Set<(event: NetcattyPluginViewClosedEvent) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], visibility: boolean[] = [];
let defer: ((value: { instanceId: string }) => void) | undefined;
let deferOpen = false;
let delayLayout = false;
const rejectOldLayout: Array<(error: Error) => void> = [];
const layoutResult = () => delayLayout ? new Promise<void>((_resolve, reject) => { rejectOldLayout.push(reject); }) : Promise.resolve();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [];
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed = fn; return () => { changed = undefined; }; },
onPluginViewClosed(fn: (event: NetcattyPluginViewClosedEvent) => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return deferOpen ? new Promise<{ instanceId: string }>((resolve) => { defer = resolve; }) : { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); }, async setPluginViewBounds() { await layoutResult(); },
async setPluginViewVisibility(_id: string, visible: boolean) { visibility.push(visible); await layoutResult(); },
onHostDrawnPluginViewUpdate() { return () => {}; },
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closeHostDrawnPluginView(id: string) { closes.push(id); },
} });
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
function Fixture() {
@@ -58,44 +53,26 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
assert.ok(document.querySelector('[data-section="vault-main"] [data-plugin-view-slot="vault-page"]'));
assert.equal(opens.length, 1);
assert.deepEqual(opens[0].context, { 'netcatty.surface': 'vault-page' }, 'no Vault/session data accompanies navigation');
assert.equal(visibility.at(-1), true);
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.equal(visibility.at(-1), false, 'native content yields to host security/settings dialogs');
await act(async () => { dialog.remove(); await settle(); });
assert.equal(visibility.at(-1), true);
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); assert.ok(closes.includes(opens[0].instanceId!)); await settle(); });
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); await settle(); });
assert.equal(closes.length, 0, 'hiding keeps the logical view available');
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
assert.equal(opens.length, 2);
assert.equal(opens.length, 1, 'returning to Vault reuses the same plugin state');
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
await act(async () => { await settle(); });
assert.ok(closes.includes(opens[1].instanceId!));
// The main process owns immediate invalidation on disable/uninstall.
await act(async () => { for (const listener of listeners) listener({ instanceId: opens[0].instanceId!, viewId: page.id, pluginId: 'com.example.page', reason: 'plugin-disabled' }); });
assert.ok(closes.includes(opens[0].instanceId!));
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
assert.equal(document.activeElement?.textContent, 'Hosts');
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
deferOpen = true;
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
assert.equal(opens.length, 3);
assert.equal(opens.length, 2);
await act(async () => { selectSection('hosts'); await settle(); });
assert.ok(closes.includes(opens[2].instanceId!), 'cancel reserved instance before its open response');
await act(async () => { defer!({ instanceId: opens[2].instanceId! }); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]'), null, 'late open cannot restore withdrawn page');
assert.ok(closes.filter((id) => id === opens[2].instanceId).length >= 2, 'late native response is also closed');
deferOpen = false;
const second = { ...page, id: 'com.example.page.second', title: 'Second page' };
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [page, second] }] }; changed?.(); await settle(); });
delayLayout = true;
await clickPage();
assert.ok(rejectOldLayout.length >= 2, 'old page has both bounds and visibility IPC in flight');
await act(async () => { selectSection('hosts'); await settle(); });
delayLayout = false;
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!.click(); await settle(); });
await act(async () => { for (const reject of rejectOldLayout) reject(new Error('old page layout failed')); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]')?.getAttribute('aria-label'), 'Second page', 'old bounds/visibility failures cannot withdraw the newly selected page');
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+20 -11
View File
@@ -1,11 +1,11 @@
import React, { useState } from 'react';
import { UserRound } from 'lucide-react';
import { Globe, HardDrive, Store } from 'lucide-react';
import { usePluginAuthorIdentity } from '../../application/state/usePluginAuthorIdentity';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Badge } from '../ui/badge';
export function PluginAuthorRow({ entry, plugin, sourceId }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
export function PluginAuthorRow({ entry, plugin, sourceId, showSource = false }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string; showSource?: boolean;
}) {
const { t } = useI18n();
const { ref, identity } = usePluginAuthorIdentity({ entry, plugin, sourceId });
@@ -14,13 +14,22 @@ export function PluginAuthorRow({ entry, plugin, sourceId }: {
const imageKey = JSON.stringify([entry?.id, entry?.selectionToken, plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, sourceId, identity?.avatar]);
const avatar = identity?.avatar;
const showImage = avatar && avatar.length <= 700_000 && /^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(avatar) && failed !== imageKey;
return <div ref={ref} className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground" data-plugin-author-row>
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted/60" aria-hidden="true">
{showImage ? <img src={avatar} alt="" width={20} height={20} className="h-full w-full object-cover" onError={() => setFailed(imageKey)} />
: <UserRound size={13} data-plugin-author-placeholder />}
</span>
<span className="min-w-0 truncate" title={author}>{author}</span>
{identity?.officialMaintained === true && <Badge className="shrink-0 whitespace-nowrap border-0 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-500/15 dark:bg-amber-400/15 dark:text-amber-300 dark:hover:bg-amber-400/15"
title={t('settings.plugins.author.officialMeaning')} data-plugin-official-maintainer>{t('settings.plugins.author.official')}</Badge>}
const source = plugin?.source;
const sourceKey = source?.kind === 'third-party' ? 'thirdparty' : source?.kind === 'official' || source?.kind === 'local' ? source.kind : undefined;
const sourceLabel = t(sourceKey ? `settings.plugins.sources.short.${sourceKey}` : 'settings.plugins.landing.sourceUnknown');
const sourceDescription = sourceKey
? [t(`settings.plugins.sources.${sourceKey}`), source?.name, source?.origin].filter(Boolean).join(' · ')
: sourceLabel;
const SourceIcon = source?.kind === 'local' ? HardDrive : source?.kind === 'official' ? Store : Globe;
return <div ref={ref} className="flex min-w-0 items-center gap-3 overflow-hidden text-xs text-muted-foreground" data-plugin-author-row>
{(author || showImage || identity?.officialMaintained === true) && <span data-plugin-author className="inline-flex min-w-0 items-center gap-1.5 overflow-hidden">
{showImage && <img src={avatar} alt="" width={16} height={16} className="h-4 w-4 shrink-0 rounded-full object-cover" onError={() => setFailed(imageKey)} />}
{author && <span className="min-w-0 truncate" title={author}>{author}</span>}
{identity?.officialMaintained === true && <Badge className="shrink-0 whitespace-nowrap border-0 bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-800 hover:bg-amber-500/15 dark:bg-amber-400/15 dark:text-amber-300 dark:hover:bg-amber-400/15"
title={t('settings.plugins.author.officialMeaning')} data-plugin-official-maintainer>{t('settings.plugins.author.official')}</Badge>}
</span>}
{showSource && <span data-plugin-source className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap" title={sourceDescription} aria-label={sourceDescription}>
<SourceIcon size={12} aria-hidden="true" />{sourceLabel}
</span>}
</div>;
}
+17 -5
View File
@@ -7,9 +7,12 @@ import {
} from '../../application/state/usePluginViewLifecycle';
import { Button } from '../ui/button';
import { PluginContributionIcon } from './PluginContributionIcon';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
export { requestOpenPluginView };
const subscribeNoop = () => () => {};
const DEFAULT_KEYBINDING_CONTEXT = Object.freeze({ 'netcatty.surface': 'keybinding' });
export function PluginContributionHost({
@@ -28,7 +31,7 @@ export function PluginContributionHost({
activeView,
close,
effectiveRequested,
mountRef,
viewQueryContext,
} = usePluginViewLifecycle({
locale,
theme,
@@ -38,18 +41,24 @@ export function PluginContributionHost({
if (!effectiveRequested || !activeView) return null;
const location = activeView.view.location;
const content = <PluginHostDrawnPage pluginId={activeView.plugin.id} viewId={activeView.view.id} version={activeView.plugin.version} slot={`view:${activeView.view.id}`} active context={viewQueryContext}
subscribeHidden={subscribeNoop} unavailable={() => void close()} loadingLabel={t('plugins.vault.loading')} />;
const containerClass = location === 'aside'
? 'absolute inset-y-0 right-0 z-40 w-[420px] border-l border-border bg-background shadow-2xl'
: location === 'tab'
? 'absolute inset-0 z-40 flex p-2 pt-0'
: location === 'panel'
? 'absolute inset-x-0 bottom-0 z-40 h-[42%] border-t border-border bg-background shadow-2xl'
: location === 'modal'
? 'fixed left-1/2 top-1/2 z-50 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
? 'fixed left-1/2 top-1/2 z-40 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
: 'absolute inset-0 z-40 bg-background';
if (location === 'tab') {
return (
<section className={`${containerClass} flex flex-col`} role="region" aria-label={activeView.view.title}>
<div ref={mountRef} className="min-h-0 flex-1" />
<section className={containerClass} role="region" aria-label={activeView.view.title}>
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm">
{content}
</div>
</section>
);
}
@@ -58,6 +67,7 @@ export function PluginContributionHost({
<section
className={`${containerClass} flex flex-col`}
role={location === 'modal' ? 'dialog' : 'region'}
data-plugin-dialog={location === 'modal' ? 'true' : undefined}
aria-modal={location === 'modal' ? true : undefined}
aria-label={activeView.view.title}
>
@@ -80,7 +90,9 @@ export function PluginContributionHost({
<X size={14} />
</Button>
</header>
<div ref={mountRef} className="min-h-0 flex-1" />
<div className={location === 'modal' ? 'flex min-h-0 flex-1 p-3' : 'flex min-h-0 flex-1'}>
{content}
</div>
</section>
);
}
@@ -0,0 +1,15 @@
import { useHostDrawnPluginView } from '../../application/state/useHostDrawnPluginView';
import { PluginSettingsEntry } from './PluginSettingsEntry';
import { PluginRemoteSurface } from './PluginRemoteSurface';
export function PluginHostDrawnPage(props: Parameters<typeof useHostDrawnPluginView>[0] & { loadingLabel: string; pluginId: string }) {
const view = useHostDrawnPluginView(props);
if (!view.visible) return null;
if (view.loading) return <p data-plugin-view-slot={props.slot.split(':')[0]} role="status" className="p-4 text-sm text-muted-foreground">{props.loadingLabel}</p>;
return <div className="relative isolate h-full min-h-0 w-full flex-1" data-plugin-view-slot={props.slot.split(':')[0]}>
<PluginRemoteSurface key={`${props.viewId}:${props.version}:${props.slot}`} tree={view.tree} css={view.css} settled={view.settled} loadImage={view.loadImage} onEvent={view.sendEvent} />
<div className="absolute right-3 top-3 z-10" data-plugin-settings-entry="">
<PluginSettingsEntry key={`${props.pluginId}:${props.version}`} pluginId={props.pluginId} />
</div>
</div>;
}
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('plugin surface forwards its own input and contains malformed updates and render errors', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div><input id="host" value="private">', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent', 'ShadowRoot']) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
let captured: ShadowRoot | undefined;
const nativeAttach = dom.window.Element.prototype.attachShadow;
dom.window.Element.prototype.attachShadow = function (options) { captured = nativeAttach.call(this, options) as unknown as ShadowRoot; return captured as unknown as globalThis.ShadowRoot; };
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginRemoteSurface } = await import('./PluginRemoteSurface');
const events: unknown[] = [];
const tree = { children: [{ id: '1', type: 1, element: 'div', attributes: { class: 'card', style: 'display:grid;gap:8px' }, children: [
{ id: '2', type: 1, element: 'button', attributes: { type: 'button', 'data-netcatty-remote-node': 'forged' }, children: [{ id: '3', type: 3, data: '<b>Hello</b>' }] },
{ id: '4', type: 1, element: 'input', attributes: { type: 'text', value: '' }, children: [] },
] }] };
const root = createRoot(document.getElementById('root')!);
try {
await act(async () => { root.render(<PluginRemoteSurface tree={tree} css=".card{color:red}" onEvent={(event) => events.push(event)} />); });
assert.ok(captured); assert.equal(document.querySelector('[data-plugin-remote-surface]')?.querySelector('button'), null);
assert.equal(captured!.querySelector('button')?.textContent, '<b>Hello</b>');
assert.equal(captured!.querySelector('button')?.getAttribute('data-netcatty-remote-node'), '2');
await act(async () => { captured!.querySelector('button')!.click(); });
assert.deepEqual(events, [{ nodeId: '2', type: 'click' }]);
const input = captured!.querySelector('input')!; input.value = 'typed';
await act(async () => { input.dispatchEvent(new dom.window.Event('input', { bubbles: true })); });
assert.deepEqual(events.at(-1), { nodeId: '4', type: 'input', value: 'typed' });
await act(async () => { document.getElementById('host')!.dispatchEvent(new dom.window.Event('input', { bubbles: true })); });
assert.equal(events.length, 2);
await act(async () => { root.render(<PluginRemoteSurface tree={tree} css="@import 'https://bad.example/x';" onEvent={() => {}} />); });
assert.equal(document.querySelector('[role=alert]')?.textContent, 'Unable to display plugin view.');
let clicks = 0;
const renderView = async (nextTree: unknown) => act(async () => root.render(<>
<PluginRemoteSurface key="view" tree={nextTree} css="" onEvent={() => {}} />
<button id="canary" onClick={() => clicks++}>Host canary</button>
<PluginRemoteSurface key="sibling" tree={tree} css="" onEvent={() => {}} />
</>));
await renderView(tree);
await renderView({ children: [{ id: 'bad', type: 1, element: 'input', attributes: {}, children: [{ id: 'text', type: 3, data: 'invalid' }] }] });
assert.equal(document.querySelectorAll('[data-plugin-remote-surface]').length, 1);
assert.ok(document.querySelector('[role=alert]'));
await act(async () => document.getElementById('canary')!.click());
assert.equal(clicks, 1);
// Validated data can still hit a React rendering constraint. Contain that
// descendant throw too, independently of the policy rejection above.
const originalError = console.error;
try {
console.error = () => {};
await renderView({ children: [{ id: 'bad', type: 1, element: 'textarea', attributes: {}, children: [
{ id: 'a', type: 3, data: 'one' }, { id: 'b', type: 3, data: 'two' },
] }] });
} finally { console.error = originalError; }
assert.equal(document.querySelectorAll('[data-plugin-remote-surface]').length, 1);
assert.ok(document.querySelector('[role=alert]'));
await act(async () => document.getElementById('canary')!.click());
assert.equal(clicks, 2);
} finally {
await act(async () => { root.unmount(); });
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
test('closed-shadow editing keeps app close shortcuts in Vault, sidebar, tabs and popovers', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
HTMLElement: dom.window.HTMLElement, Element: dom.window.Element, Node: dom.window.Node, IS_REACT_ACT_ENVIRONMENT: true })) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
dom.window.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 20, y: 20, width: 240, height: 30, top: 20, left: 20, right: 260, bottom: 50, toJSON() {} });
Object.assign(dom.window, { ResizeObserver: class { observe() {} disconnect() {} } });
const shadows: ShadowRoot[] = [];
const nativeAttach = dom.window.Element.prototype.attachShadow;
dom.window.Element.prototype.attachShadow = function (options) {
const shadow = nativeAttach.call(this, options); shadows.push(shadow as unknown as ShadowRoot); return shadow;
};
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginRemoteSurface } = await import('./PluginRemoteSurface');
const { handleGlobalHotkeyKeyDownImpl } = await import('../../application/app/AppHandlers');
const { isPluginViewEditingTarget, isPluginShortcutEditableEvent } = await import('../../application/state/pluginKeybindings');
const { matchesKeyBinding } = await import('../../domain/models');
const { DEFAULT_KEY_BINDINGS } = await import('../../domain/models/keyBindings');
const actions: string[] = [];
const capture = (event: KeyboardEvent) => handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false, closeTabKeyStr: 'Ctrl + W', hotkeyScheme: 'pc', keyBindings: DEFAULT_KEY_BINDINGS, matchesKeyBinding,
executeHotkeyAction: (action: string) => actions.push(action),
}), event);
dom.window.addEventListener('keydown', capture as unknown as EventListener, true);
const control = (id: string, element: string, attributes = {}) => ({ id, type: 1, element, attributes, children: [] });
const tree = { children: [control('input', 'input'), control('textarea', 'textarea'), control('anchor', 'button', { id: 'anchor' }),
{ ...control('popover', 'div', { 'data-plugin-popover': 'true', 'data-plugin-anchor': 'anchor', open: '' }), children: [control('pop-input', 'input'), control('pop-textarea', 'textarea')] },
] };
const root = createRoot(document.getElementById('root')!);
try {
for (const slot of ['vault-page', 'terminal-sidebar', 'plugin-tab']) {
shadows.length = 0;
await act(async () => root.render(<div key={slot} data-plugin-view-slot={slot}><PluginRemoteSurface tree={tree} css="" onEvent={() => {}} /></div>));
assert.equal(shadows.length, 2, 'includes the portaled popover surface');
for (const shadow of shadows) {
for (const element of shadow.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('input,textarea')) {
await act(async () => element.focus());
// Native menu IPC sees this same retargeted document.activeElement.
assert.equal(isPluginViewEditingTarget(document.activeElement), true, `${slot}: ${element.tagName}`);
let editable = false;
const observe = (event: Event) => { editable = isPluginShortcutEditableEvent(event); assert.equal(event.target, shadow.host); };
dom.window.addEventListener('keydown', observe, { once: true });
await act(async () => element.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'w', code: 'KeyW', ctrlKey: true, bubbles: true, composed: true, cancelable: true })));
assert.equal(editable, true);
assert.deepEqual(actions, []);
}
}
await act(async () => (shadows[0].querySelector('button') as HTMLButtonElement).focus());
assert.equal(isPluginViewEditingTarget(document.activeElement), false, 'focus marker clears when editing ends');
}
} finally {
dom.window.removeEventListener('keydown', capture as unknown as EventListener, true);
await act(async () => root.unmount()); dom.window.close();
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
}
});
+120
View File
@@ -0,0 +1,120 @@
import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
import { PluginViewPopover } from './PluginViewPopover';
import { createPortal } from 'react-dom';
import { validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
type RemoteNode = { id: string; type: 3; data: string } | { id: string; type: 8; data: string } | {
id: string; type: 1; element: string; attributes: Record<string, string>; children: RemoteNode[];
};
export type PluginSurfaceEvent = import('@netcatty/plugin-contract').HostDrawnViewEvent;
const ATTRIBUTE_NAMES: Record<string, string> = { class: 'className', for: 'htmlFor', readonly: 'readOnly', tabindex: 'tabIndex', maxlength: 'maxLength', minlength: 'minLength', colspan: 'colSpan', rowspan: 'rowSpan', autocomplete: 'autoComplete' };
const BOOLEAN_ATTRIBUTES = new Set(['checked', 'disabled', 'readonly', 'required', 'multiple', 'selected', 'open']);
const SurfaceContext = createContext<{ css: string; onEvent(event: PluginSurfaceEvent): void; depth: number }>({ css: '', onEvent: () => {}, depth: 0 });
const ResourceLoader = createContext<((path: string) => Promise<string>) | undefined>(undefined);
function RemoteElement({ node, settled }: { node: RemoteNode; settled: boolean }): React.ReactNode {
const attrs = node.type === 1 ? node.attributes : {};
const surface = useContext(SurfaceContext);
const loadImage = useContext(ResourceLoader);
const imagePath = node.type === 1 && node.element === 'img' ? attrs.src?.slice(6) : undefined;
const [imageUrl, setImageUrl] = useState<string | undefined>();
useEffect(() => {
let cancelled = false; setImageUrl(undefined);
if (imagePath && loadImage) void loadImage(imagePath).then((url) => { if (!cancelled) setImageUrl(url); }).catch(() => {});
return () => { cancelled = true; };
}, [imagePath, loadImage]);
const remoteSelection = JSON.stringify(node.type === 1 && node.element === 'select'
? node.children.filter((child) => child.type === 1 && child.element === 'option' && Object.hasOwn(child.attributes, 'selected')).map((child) => child.type === 1 ? child.attributes.value ?? '' : '') : []);
const [localSelection, setLocalSelection] = useState<string[]>(() => JSON.parse(remoteSelection));
useEffect(() => { if (settled) setLocalSelection(JSON.parse(remoteSelection)); }, [remoteSelection, settled]);
const remoteValue = attrs.value;
const remoteChecked = Object.hasOwn(attrs, 'checked');
const [localValue, setLocalValue] = useState(remoteValue ?? '');
const [localChecked, setLocalChecked] = useState(remoteChecked);
useEffect(() => { if (settled && remoteValue !== undefined) setLocalValue(remoteValue); }, [remoteValue, settled]);
useEffect(() => { if (settled) setLocalChecked(remoteChecked); }, [remoteChecked, settled]);
if (node.type === 3) return node.data;
if (node.type === 8) return null;
if (attrs['data-plugin-popover'] === 'true') return surface.depth < 1 ? <PluginViewPopover anchor={attrs['data-plugin-anchor'] ?? ''} open={Object.hasOwn(attrs, 'open')} dismiss={() => surface.onEvent({ nodeId: node.id, type: 'dismiss' })}><PluginRemoteSurface tree={{ children: node.children }} css={surface.css} onEvent={surface.onEvent} settled={settled} loadImage={loadImage} depth={surface.depth + 1} /></PluginViewPopover> : null;
const props: Record<string, unknown> = { 'data-netcatty-remote-node': node.id };
let inlineStyle: string | undefined;
for (const [name, value] of Object.entries(node.attributes)) {
if (name === 'src' || (node.element === 'option' && name === 'selected')) continue;
if (name === 'style') { inlineStyle = value; continue; }
if (name === 'data-netcatty-remote-node') continue;
props[ATTRIBUTE_NAMES[name] ?? name] = BOOLEAN_ATTRIBUTES.has(name) ? true : value;
}
// The policy validated this declaration list before this component rendered.
props.ref = (el: HTMLElement | null) => { if (el) { if (inlineStyle === undefined) el.removeAttribute('style'); else el.setAttribute('style', inlineStyle); } };
if (node.element === 'img') props.src = imageUrl;
if (node.element === 'form') props.onSubmit = (event: React.FormEvent) => event.preventDefault();
if (['input', 'select', 'textarea'].includes(node.element)) {
if (node.element === 'select' && Object.hasOwn(attrs, 'multiple')) props.value = localSelection;
else if (remoteValue !== undefined) props.value = localValue;
props.autoComplete = 'off';
if (node.element === 'input' && ['checkbox', 'radio'].includes(attrs.type ?? '')) props.checked = localChecked;
props.onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setLocalValue(event.currentTarget.value);
if (event.currentTarget instanceof event.currentTarget.ownerDocument.defaultView!.HTMLSelectElement && event.currentTarget.multiple) setLocalSelection([...event.currentTarget.selectedOptions].map((option) => option.value));
if (event.currentTarget instanceof event.currentTarget.ownerDocument.defaultView!.HTMLInputElement) setLocalChecked(event.currentTarget.checked);
};
}
return React.createElement(node.element, props, ...(node.element === 'textarea' && remoteValue !== undefined ? [] : node.children).map((child) => <RemoteElement key={child.id} node={child} settled={settled} />));
}
/** Host-owned DOM, kept inside a closed shadow root and a clipped stacking context. */
export function PluginRemoteSurface({ tree, css, onEvent, settled = true, loadImage, depth = 0 }: { depth?: number; tree: unknown; css: string; settled?: boolean; loadImage?(path: string): Promise<string>; onEvent(event: PluginSurfaceEvent): void }) {
const checked = useMemo(() => {
try {
validatePluginRemoteTree(tree);
return { tree: tree as { children: RemoteNode[] }, css: validatePluginStylesheet(css) };
} catch { return null; }
}, [tree, css]);
if (!checked) return <p role="alert" className="p-4 text-sm text-destructive">Unable to display plugin view.</p>;
return <LazyLoadBoundary name="Plugin view" fallback={<p role="alert" className="p-4 text-sm text-destructive">Unable to display plugin view.</p>}><SurfaceContext.Provider value={{ css: checked.css, onEvent, depth }}><ResourceLoader.Provider value={loadImage}><ValidatedPluginRemoteSurface tree={checked.tree} css={checked.css} onEvent={onEvent} settled={settled} /></ResourceLoader.Provider></SurfaceContext.Provider></LazyLoadBoundary>;
}
function ValidatedPluginRemoteSurface({ tree, css, onEvent, settled }: { settled: boolean; tree: { children: RemoteNode[] }; css: string; onEvent(event: PluginSurfaceEvent): void }) {
const hostRef = useRef<HTMLDivElement>(null);
const onEventRef = useRef(onEvent); onEventRef.current = onEvent;
const shadowRef = useRef<ShadowRoot | null>(null);
const [shadow, setShadow] = useState<ShadowRoot | null>(null);
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
// The same DOM node can be mounted twice in React Strict Mode.
const root = shadowRef.current ?? host.attachShadow({ mode: 'closed' });
shadowRef.current = root;
setShadow(root);
}, []);
useEffect(() => {
if (!shadow) return;
const listener = (event: Event) => {
const target = event.target;
const view = shadow.ownerDocument.defaultView;
if (!view || !(target instanceof view.HTMLElement) || !['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset'].includes(event.type)) return;
// Closed-shadow events hide their editable target from window capture.
if (event.type === 'focusin') hostRef.current?.toggleAttribute('data-plugin-editing', target.matches('input, textarea, select, [contenteditable]'));
if (event.type === 'focusout') hostRef.current?.removeAttribute('data-plugin-editing');
if (event.type === 'submit') event.preventDefault();
const nodeId = target.dataset.netcattyRemoteNode;
if (!nodeId) return;
const value = target instanceof view.HTMLInputElement || target instanceof view.HTMLTextAreaElement || target instanceof view.HTMLSelectElement ? target.value : undefined;
const values = target instanceof view.HTMLSelectElement && target.multiple ? [...target.selectedOptions].map((option) => option.value) : undefined;
const checkedValue = target instanceof view.HTMLInputElement && ['checkbox', 'radio'].includes(target.type) ? target.checked : undefined;
if (value !== undefined && value.length > 8192) return;
const keyboard = event instanceof view.KeyboardEvent && event.key.length <= 64
? { key: event.key, ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, shiftKey: event.shiftKey } : {};
onEventRef.current({ nodeId, type: event.type as PluginSurfaceEvent['type'], ...(value === undefined ? {} : { value }), ...(checkedValue === undefined ? {} : { checked: checkedValue }), ...keyboard, ...(values ? { values } : {}), ...(event instanceof view.CompositionEvent ? { data: event.data.slice(0, 8192) } : {}) });
};
for (const event of ['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset']) shadow.addEventListener(event, listener, true);
return () => { for (const event of ['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset']) shadow.removeEventListener(event, listener, true); };
}, [shadow]);
return <div className="h-full w-full min-h-0 min-w-0 overflow-hidden [contain:paint] isolate relative" data-plugin-remote-surface>
<div ref={hostRef} className="h-full min-h-0 overflow-auto [container-type:inline-size]" />
{shadow && createPortal(<><style>{css}</style>{tree.children.map((node) => <RemoteElement key={node.id} node={node} settled={settled} />)}</>, shadow)}
</div>;
}
@@ -0,0 +1,85 @@
import { Settings } from 'lucide-react';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { usePluginSettingsModel } from '../../application/state/usePluginSettingsModel';
import { useAvailableFonts } from '../../application/state/fontStore';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import { PluginSettingGroups } from './PluginSettingsForm';
export const pluginSettingsButtonClass = 'h-8 w-8 shrink-0 rounded-full border border-border/60 bg-background/95 shadow-sm hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
export function PluginSettingsDialog({ pluginId, onClose, restoreFocusTo }: {
pluginId: string;
onClose: () => void;
restoreFocusTo: RefObject<HTMLElement | null>;
}) {
const { t } = useI18n();
const model = usePluginSettingsModel(pluginId);
const fonts = useAvailableFonts();
useEffect(() => {
if (!model.loading && !model.error && !model.settings.length) onClose();
}, [model.loading, model.error, model.settings.length, onClose]);
return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>
<DialogContent data-plugin-settings-dialog={pluginId}
className="app-no-drag flex max-h-[min(80dvh,760px)] w-[calc(100vw-2rem)] max-w-[720px] flex-col gap-0 overflow-hidden rounded-xl p-0"
overlayClassName="bg-black/40"
onEscapeKeyDown={event => event.stopPropagation()}
onCloseAutoFocus={event => {
const element = restoreFocusTo.current;
if (element?.isConnected && !element.matches(':disabled')) {
event.preventDefault(); element.focus({ preventScroll: true });
}
}}>
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-4 pr-14">
<DialogTitle className="flex min-w-0 items-center gap-2.5 text-base">
<Settings size={18} aria-hidden="true" className="shrink-0 text-muted-foreground" />
<span className="truncate">{model.plugin?.displayName ?? t('settings.plugins.configuration')}</span>
</DialogTitle>
</DialogHeader>
<div className="min-h-0 overflow-y-auto p-6" data-plugin-settings-form="">
{model.loading && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{model.error && <p role="alert" className="text-sm text-destructive">{model.error.message}</p>}
{model.contextualScopes.length > 0 && <section className="mb-4 grid gap-3 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{model.contextualScopes.map(kind => <label key={kind} className="space-y-1 text-xs font-medium">
<span>{t('settings.plugins.scopeTarget').replace('{scope}', kind)}</span>
<select value={model.scopeIds[kind] ?? ''}
onChange={event => model.setScopeIds(current => ({ ...current, [kind]: event.target.value || undefined }))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-normal">
{!model.catalog[kind].length && <option value="">{t('settings.plugins.noScopeTargets')}</option>}
{model.catalog[kind].map(entry => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select>
</label>)}
</section>}
<div className="space-y-4">
<PluginSettingGroups pluginId={pluginId} settings={model.settings} updateSetting={model.updateSetting}
resetSetting={model.resetSetting} selectSettingPath={model.selectSettingPath} availableFonts={fonts} />
</div>
</div>
</DialogContent>
</Dialog>;
}
function AvailableSettingsEntry({ pluginId, name }: { pluginId: string; name: string }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const opener = useRef<HTMLButtonElement>(null);
return <>
<div className="flex items-center gap-3">
<Button ref={opener} type="button" variant="ghost" size="icon" className={pluginSettingsButtonClass}
aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t('common.settings')}
onClick={() => setOpen(true)}><Settings size={16} /></Button>
</div>
{open && <PluginSettingsDialog pluginId={pluginId} onClose={() => setOpen(false)} restoreFocusTo={opener} />}
</>;
}
export function PluginSettingsEntry({ pluginId }: { pluginId: string }) {
const { locale } = useI18n();
const contributions = usePluginContributions({ locale, context: { 'netcatty.surface': 'settings' } });
const plugin = contributions.snapshot.plugins.find(item => item.id === pluginId);
if (!plugin?.settings.some(setting => setting.visible)) return null;
// Removing or replacing the owning contribution also discards its open dialog.
return <AvailableSettingsEntry key={`${plugin.id}:${plugin.version}`} pluginId={plugin.id} name={plugin.displayName} />;
}
+275
View File
@@ -0,0 +1,275 @@
import React, { useEffect, useState } from 'react';
import { FolderOpen, RotateCcw } from 'lucide-react';
import { normalizePluginKeyboardEvent } from '../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Toggle } from '../settings/settings-ui';
import { parsePluginStructuredSettingValue } from '../settings/tabs/pluginSettingValues';
import { PluginStructuredSettingEditor } from '../settings/tabs/PluginStructuredSettingEditor';
import { TerminalFontSelect } from '../settings/TerminalFontSelect';
export function PluginSettingField({
pluginId,
setting,
updateSetting,
resetSetting,
selectSettingPath,
availableFonts,
}: {
pluginId: string;
setting: NetcattyPluginSettingContribution;
updateSetting: ReturnType<typeof usePluginContributions>['updateSetting'];
resetSetting: ReturnType<typeof usePluginContributions>['resetSetting'];
selectSettingPath: ReturnType<typeof usePluginContributions>['selectSettingPath'];
availableFonts: ReturnType<typeof useAvailableFonts>;
}) {
const { t } = useI18n();
const [value, setValue] = useState<unknown>(setting.secret ? '' : setting.value ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const itemLabel = (key: 'settings.plugins.moveItemUp' | 'settings.plugins.moveItemDown', index: number) => (
t(key).replace('{label}', setting.label).replace('{index}', String(index + 1))
);
useEffect(() => {
setValue(setting.secret ? '' : setting.value ?? '');
}, [setting.secret, setting.value]);
const save = async (nextValue: unknown) => {
setSaving(true);
setError(null);
try {
await updateSetting(pluginId, setting.id, nextValue, setting.scopeId ?? undefined);
if (setting.secret) setValue('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const control = (() => {
if (setting.scopeId == null) {
return <div className="text-xs text-muted-foreground">{t('settings.plugins.scopeContext').replace('{scope}', setting.scope)}</div>;
}
if (setting.control === 'switch') {
return <Toggle ariaLabel={setting.label} checked={Boolean(value)} disabled={saving}
onChange={(next) => { setValue(next); void save(next); }} />;
}
if (setting.control === 'radio') {
return (
<fieldset className="space-y-2" disabled={saving}>
<legend className="sr-only">{setting.label}</legend>
{setting.options?.map((option) => (
<label key={option.value} className="flex items-start gap-2 text-sm">
<input
type="radio"
name={setting.id}
value={option.value}
checked={value === option.value}
onChange={() => { setValue(option.value); void save(option.value); }}
className="mt-0.5 accent-primary"
/>
<span>{option.label}{option.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}</span>
</label>
))}
</fieldset>
);
}
if (setting.control === 'select') {
return (
<select
aria-label={setting.label}
value={String(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.value); void save(event.target.value); }}
className="h-9 min-w-52 rounded-md border border-input bg-background px-3 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'multiselect') {
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<select
aria-label={setting.label}
multiple
value={selected}
disabled={saving}
onChange={(event) => {
const next = [...event.currentTarget.selectedOptions].map((option) => option.value);
setValue(next);
void save(next);
}}
className="min-h-24 min-w-52 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'textarea') {
return (
<textarea
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => void save(event.currentTarget.value)}
className="min-h-24 w-full max-w-xl rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
);
}
if (setting.control === 'number' || setting.control === 'slider') {
return (
<Input
aria-label={setting.label}
type={setting.control === 'slider' ? 'range' : 'number'}
value={typeof value === 'number' ? value : Number(value) || 0}
min={setting.minimum}
max={setting.maximum}
step={setting.step}
disabled={saving}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => void save(Number(event.currentTarget.value))}
className="max-w-sm"
/>
);
}
if (setting.control === 'list' || setting.control === 'table') {
return (
<PluginStructuredSettingEditor
setting={setting}
value={typeof value === 'string' ? parsePluginStructuredSettingValue(value) : value}
disabled={saving}
onChange={setValue}
onCommit={(next) => void save(next)}
labels={{
add: t('settings.plugins.addItem'),
remove: t('settings.plugins.removeItem'),
moveUp: (index) => itemLabel('settings.plugins.moveItemUp', index),
moveDown: (index) => itemLabel('settings.plugins.moveItemDown', index),
}}
/>
);
}
if (setting.control === 'font') {
return (
<TerminalFontSelect
value={String(value)}
fonts={availableFonts}
disabled={saving}
onChange={(next) => { setValue(next); void save(next); }}
className="w-full max-w-xl"
ariaLabel={setting.label}
/>
);
}
if (setting.control === 'file' || setting.control === 'directory') {
return (
<div className="flex max-w-xl gap-2">
<Input aria-label={setting.label} value={String(value)} readOnly disabled={saving} className="min-w-0 flex-1" />
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => void selectSettingPath(setting.control as 'file' | 'directory', setting.label, String(value || '')).then((selected) => {
if (!selected) return;
setValue(selected);
void save(selected);
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))}
>
<FolderOpen size={14} className="mr-2" /> {t('settings.plugins.browse')}
</Button>
</div>
);
}
if (setting.control === 'keybinding') {
return (
<Input
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder ?? t('settings.plugins.pressKeybinding')}
readOnly
disabled={saving}
onKeyDown={(event) => {
event.preventDefault();
const key = normalizePluginKeyboardEvent(event.nativeEvent);
if (!key) return;
setValue(key);
void save(key);
}}
className="max-w-xl"
/>
);
}
return (
<Input
aria-label={setting.label}
type={setting.secret || setting.control === 'password' ? 'password' : setting.control === 'color' ? 'color' : 'text'}
value={String(value)}
placeholder={setting.secret && setting.configured ? t('settings.plugins.configuredReplacement') : setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => {
const next = event.currentTarget.value;
if (!setting.secret || next.length > 0) void save(next);
}}
className="max-w-xl"
/>
);
})();
const expanded = ['textarea', 'list', 'table', 'radio', 'multiselect'].includes(setting.control);
const reset = !setting.required && setting.scopeId != null && <Button
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground"
title={t('common.reset')} aria-label={t('common.reset')} disabled={saving}
onClick={() => {
setSaving(true); setError(null);
void resetSetting(pluginId, setting.id, setting.scopeId ?? undefined)
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setSaving(false));
}}><RotateCcw size={14} /></Button>;
return <div className="space-y-2 py-3" data-plugin-setting={setting.id}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-medium">{setting.label}</div>
{setting.description && <p className="mt-1 max-w-2xl text-xs leading-relaxed text-muted-foreground">{setting.description}</p>}
{setting.restartRequired && <span className="text-xs text-amber-700 dark:text-amber-300">{t('settings.plugins.restartRequired')}</span>}
</div>
<div className="flex max-w-full items-center justify-end gap-2">
{!expanded && <div className={setting.control === 'switch' ? 'shrink-0' : 'w-56 max-w-full'}>{control}</div>}
{reset}
</div>
</div>
{expanded && control}
{(saving || (setting.secret && setting.configured) || error) && <div className="flex flex-wrap items-center gap-2">
{saving && <span role="status" className="text-xs text-muted-foreground">{t('settings.plugins.saving')}</span>}
{setting.secret && setting.configured && <span className="text-xs text-emerald-600">{t('settings.plugins.storedSecurely')}</span>}
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
</div>}
</div>;
}
export function PluginSettingGroups({ settings, ...props }: Omit<Parameters<typeof PluginSettingField>[0], 'setting'> & {
settings: ReadonlyArray<NetcattyPluginSettingContribution>;
}) {
const groups = new Map<string, NetcattyPluginSettingContribution[]>();
for (const setting of settings) {
if (!setting.visible) continue;
const group = setting.group ?? '';
groups.set(group, [...(groups.get(group) ?? []), setting]);
}
return <>{[...groups].map(([group, fields]) => <div key={group} className="space-y-3">
{group && <h4 className="pt-2 text-sm font-medium text-muted-foreground">{group}</h4>}
<div className="divide-y divide-border/50 rounded-xl bg-muted/30 px-4">
{fields.map((setting) => <PluginSettingField key={setting.id} {...props} setting={setting} />)}
</div>
</div>)}</>;
}
+17 -24
View File
@@ -1,40 +1,40 @@
import { useMemo } from 'react';
import { Blocks } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { activeTabStore } from '../../application/state/activeTabStore';
import { subscribeSidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { collectPluginTerminalSidebarPages, PLUGIN_TERMINAL_SIDEBAR_CONTEXT, requestOpenPluginTerminalSidebar,
type PluginTerminalSidebarPage } from '../../application/state/usePluginTerminalSidebar';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
export function PluginTerminalSidebarLauncher({ sessionId }: { sessionId: string }) {
const { locale, t } = useI18n();
export function PluginTerminalSidebarLauncher({ sessionId, buttonClassName = 'h-6 w-6' }: { sessionId: string; buttonClassName?: string }) {
const { locale } = useI18n();
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
const pages = collectPluginTerminalSidebarPages(contributions.snapshot.plugins);
if (!pages.length) return null;
return <Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6" aria-label={t('plugins.sidebar.extensions')}><Blocks size={14} /></Button></PopoverTrigger>
<PopoverContent align="end" className="max-h-80 w-64 overflow-y-auto p-1">
<p className="px-2 py-1 text-xs text-muted-foreground">{t('plugins.sidebar.extensions')}</p>
{pages.map((page) => <PopoverClose asChild key={page.tool}><button type="button" className="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-sm hover:bg-accent"
return <>{pages.map((page) => <Tooltip key={page.tool}>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className={buttonClassName}
aria-label={`${page.title} — ${page.pluginName}`}
onClick={() => requestOpenPluginTerminalSidebar(page.viewId, sessionId)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} /><span className="min-w-0"><span className="block truncate">{page.title}</span><span className="block truncate text-xs text-muted-foreground">{page.pluginName}</span></span>
</button></PopoverClose>)}
</PopoverContent></Popover>;
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{page.title} — {page.pluginName}</TooltipContent>
</Tooltip>)}</>;
}
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter, getSession, unavailable, focused, focusOnReveal }: {
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, getSession, unavailable, focused, showHeader = true }: {
page: PluginTerminalSidebarPage;
tabId: string;
sessionId: string;
adapter: ReturnType<typeof usePluginContributions>;
getSession(): string | null;
unavailable(): void;
focused(): void;
focusOnReveal: boolean;
showHeader?: boolean;
}) {
const { t } = useI18n();
const subscribeHidden = useMemo(() => (hide: () => void) => {
@@ -42,15 +42,8 @@ export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter,
const stopSession = subscribeSidePanelLiveSnapshot(true, () => { if (getSession() !== sessionId) hide(); });
return () => { stopTab(); stopSession(); };
}, [getSession, sessionId, tabId]);
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version,
slot: `terminal-sidebar:${tabId}:${sessionId}`, active: true, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT,
adapter, subscribeHidden, unavailable, focused, focusOnReveal });
// The native page never receives these internal ownership IDs. Its state is
// scoped by the host; this does not authorize reading the terminal session.
return <section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label={`${page.title} — ${page.pluginName}`} data-plugin-terminal-sidebar>
<header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title} · {page.pluginName}</span></header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="terminal-sidebar">
{loading && <p role="status" className="p-3 text-xs text-muted-foreground">{t('plugins.sidebar.loading')}</p>}
</div>
{showHeader && <header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title}</span></header>}
<div className="min-h-0 flex-1" onFocusCapture={focused}><PluginHostDrawnPage pluginId={page.pluginId} viewId={page.viewId} version={page.version} slot={`terminal-sidebar:${tabId}:${sessionId}`} active context={PLUGIN_TERMINAL_SIDEBAR_CONTEXT} subscribeHidden={subscribeHidden} unavailable={unavailable} loadingLabel={t('plugins.sidebar.loading')} /></div>
</section>;
}
+7 -17
View File
@@ -1,44 +1,34 @@
import { activeTabStore } from '../../application/state/activeTabStore';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import type { usePluginVaultPages } from '../../application/state/usePluginVaultPages';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
type VaultPages = ReturnType<typeof usePluginVaultPages>;
const subscribeHidden = (hide: () => void) => activeTabStore.subscribeSync((id) => { if (id !== 'vault') hide(); });
export function PluginVaultNavigation({ model, collapsed }: { model: VaultPages; collapsed: boolean }) {
const { t } = useI18n();
if (!model.pages.length) return null;
return <div role="group" aria-label={t('plugins.vault.extensions')} className="mt-3 space-y-1 border-t border-border/50 pt-3">
{!collapsed && <p className="px-2 text-xs text-muted-foreground">{t('plugins.vault.extensions')}</p>}
return <>
{model.pages.map((page) => <Button key={page.key} type="button"
variant={model.selected?.key === page.key ? 'secondary' : 'ghost'}
className={`w-full ${collapsed ? 'justify-center px-0' : 'justify-start gap-3'}`}
className={`w-full h-10 ${collapsed ? 'justify-center p-0' : 'justify-start gap-3'} ${model.selected?.key === page.key ? 'bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40' : ''}`}
title={`${page.title} — ${page.pluginName}`}
aria-label={`${page.title} — ${page.pluginName}`}
aria-current={model.selected?.key === page.key ? 'page' : undefined}
onClick={() => model.select(page.key)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} className="shrink-0" />
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={16} className="shrink-0" />
{!collapsed && <span className="truncate">{page.title}</span>}
</Button>)}
</div>;
</>;
}
export function PluginVaultPage({ model }: { model: VaultPages }) {
const { t } = useI18n();
const page = model.selected!;
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version, slot: 'vault-page',
active: model.active, context: model.context, adapter: model.contributions, subscribeHidden, unavailable: model.fallback });
return <section className="flex min-h-0 flex-1 flex-col" aria-label={page.title} data-plugin-vault-page={page.key}>
<header className="app-no-drag flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
<div className="min-w-0"><h2 className="truncate text-sm font-medium">{page.title}</h2>
<p className="truncate text-xs text-muted-foreground">{page.pluginName} · {page.version}</p></div>
<Button variant="ghost" onClick={model.fallback}>{t('plugins.vault.back')}</Button>
</header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="vault-page">
{loading && <p role="status" className="p-4 text-sm text-muted-foreground">{t('plugins.vault.loading')}</p>}
</div>
<PluginHostDrawnPage pluginId={page.pluginId} viewId={page.viewId} version={page.version} slot="vault-page" active={model.active} context={model.context}
subscribeHidden={subscribeHidden} unavailable={model.fallback} loadingLabel={t('plugins.vault.loading')} />
</section>;
}
+41
View File
@@ -0,0 +1,41 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
/** Geometry and dismissal belong to the host; content belongs to the plugin. */
export function PluginViewPopover({ anchor, open, dismiss, children }: {
anchor: string; open: boolean; dismiss(): void; children: React.ReactNode;
}) {
const marker = useRef<HTMLSpanElement>(null);
const panel = useRef<HTMLDivElement>(null);
const dismissRef = useRef(dismiss); dismissRef.current = dismiss;
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null);
useLayoutEffect(() => {
if (!open || !marker.current) { setPosition(null); return; }
const root = marker.current.getRootNode() as ShadowRoot;
const target = root.getElementById(anchor);
if (!(target instanceof marker.current.ownerDocument.defaultView!.HTMLElement)) return;
const doc = target.ownerDocument; const win = doc.defaultView!;
const update = () => {
const bounds = target.getBoundingClientRect();
if (!bounds.width || !bounds.height) { setPosition(null); dismissRef.current(); return; }
const width = Math.min(360, Math.max(220, bounds.width), win.innerWidth - 16);
setPosition({ left: Math.max(8, Math.min(bounds.left, win.innerWidth - width - 8)), top: Math.max(8, Math.min(bounds.bottom + 4, win.innerHeight - 248)), width });
};
const dismissNow = () => { setPosition(null); dismissRef.current(); };
const key = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismissNow(); target.focus(); } };
const outside = (event: PointerEvent) => { if (!event.composedPath().includes(target) && (!panel.current || !event.composedPath().includes(panel.current))) dismissNow(); };
const blocked = () => [...doc.querySelectorAll('[role=dialog][aria-modal=true]:not([data-plugin-dialog])')].some((dialog) => {
const rect = dialog.getBoundingClientRect(); return rect.width > 0 && rect.height > 0;
});
if (blocked()) { dismissNow(); return; }
const dialogs = new win.MutationObserver(() => { if (blocked()) dismissNow(); });
dialogs.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-modal', 'hidden', 'data-state'] });
const resize = new win.ResizeObserver(update); resize.observe(target); update();
win.addEventListener('resize', update); doc.addEventListener('scroll', update, true);
doc.addEventListener('keydown', key, true); doc.addEventListener('pointerdown', outside, true);
return () => { dialogs.disconnect(); resize.disconnect(); win.removeEventListener('resize', update); doc.removeEventListener('scroll', update, true); doc.removeEventListener('keydown', key, true); doc.removeEventListener('pointerdown', outside, true); };
}, [anchor, open]);
return <><span ref={marker} hidden />{open && position && marker.current && createPortal(
<div ref={panel} role="region" aria-label="Plugin menu" data-plugin-view-popover className="fixed z-[45] overflow-hidden rounded-lg border border-border bg-background shadow-xl" style={{ ...position, height: 240, maxHeight: 'calc(100vh - 16px)', contain: 'paint', isolation: 'isolate' }}>{children}</div>, marker.current.ownerDocument.body,
)}</>;
}
+3
View File
@@ -203,6 +203,7 @@ export const SettingCard: React.FC<SettingCardProps> = ({
interface SettingRowProps {
leading?: React.ReactNode;
label?: React.ReactNode;
className?: string;
description?: React.ReactNode;
children: React.ReactNode;
align?: "center" | "start";
@@ -213,6 +214,7 @@ interface SettingRowProps {
export const SettingRow: React.FC<SettingRowProps> = ({
leading,
label,
className,
description,
children,
align = "center",
@@ -225,6 +227,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
// Keep square corners: rounded rows bend divide-y separators at both ends.
"flex justify-between py-3 gap-4",
align === "start" ? "items-start" : "items-center",
className,
)}
>
{leading && <div className="shrink-0">{leading}</div>}
@@ -12,14 +12,53 @@ const entry = { id: 'com.netcatty.starter', name: 'Starter', author: 'Netcatty',
const identity = { author: 'Netcatty', avatar: 'data:image/png;base64,eA==', officialMaintained: true,
sourceId: 'official', version: entry.version, archiveSha256: null };
test('untrusted entry strings and absent imagery produce a compact placeholder without an official badge', () => {
test('untrusted entry strings and absent imagery show only the author without decoration or an official badge', () => {
const claimed = { ...entry, author: '<img src="https://avatars.githubusercontent.com/u/1">',
officialMaintained: true, avatar: 'https://avatars.githubusercontent.com/u/1' };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginAuthorRow entry={claimed} /></I18nProvider>);
assert.match(html, /data-plugin-author-placeholder/);
assert.doesNotMatch(html, /<img|data-plugin-official-maintainer/);
assert.doesNotMatch(html, /<img|<svg|data-plugin-author-placeholder|bg-muted|rounded-full|data-plugin-official-maintainer/);
assert.match(html, /&lt;img/);
assert.match(html, /items-center/);
});
test('installed authors share a compact row with localized sources and full accessible provenance', () => {
const plugin = { id: entry.id, manifest: { publisher: 'An extremely long publisher name' },
source: { id: 'third-party:example', kind: 'third-party', name: 'Community packages', origin: 'https://packages.example.org/catalog' },
} as NetcattyInstalledPlugin;
for (const [locale, label] of [['en', 'Third-party'], ['zh-CN', '第三方']]) {
const dom = new JSDOM(renderToStaticMarkup(<I18nProvider locale={locale}><PluginAuthorRow plugin={plugin} showSource /></I18nProvider>));
const row = dom.window.document.querySelector('[data-plugin-author-row]')!;
const source = row.querySelector<HTMLElement>('[data-plugin-source]')!;
assert.ok(source, 'source belongs to the author row');
assert.equal(source.textContent, label);
assert.match(source.title, /Community packages.*packages\.example\.org/);
assert.equal(source.getAttribute('aria-label'), source.title);
assert.ok(source.classList.contains('shrink-0'));
assert.ok(row.classList.contains('min-w-0'));
assert.ok(row.classList.contains('gap-3'), 'author and source are separated by 12px');
assert.ok(row.classList.contains('overflow-hidden'), 'metadata is bounded even at extremely narrow widths');
const authorGroup = row.querySelector('[data-plugin-author]')!;
assert.equal(authorGroup.parentElement, row);
assert.equal(source.parentElement, row);
assert.ok(authorGroup.classList.contains('min-w-0'));
assert.ok(authorGroup.classList.contains('gap-1.5'), 'avatar and author have a 6px gap');
assert.ok(source.classList.contains('gap-1'), 'source icon and text have a 4px gap');
assert.ok(row.querySelector('[title="An extremely long publisher name"]')?.classList.contains('truncate'));
assert.ok(source.querySelector('svg[aria-hidden="true"]'));
assert.equal(row.querySelector('[data-plugin-official-maintainer]'), null);
dom.window.close();
}
});
test('missing authors and unknown sources do not leave an empty author group or expose translation keys', () => {
for (const source of [undefined, { kind: 'unexpected' }]) {
const plugin = { id: entry.id, manifest: {}, source } as NetcattyInstalledPlugin;
const dom = new JSDOM(renderToStaticMarkup(<I18nProvider locale="en"><PluginAuthorRow plugin={plugin} showSource /></I18nProvider>));
const row = dom.window.document.querySelector('[data-plugin-author-row]')!;
assert.equal(row.querySelector('[data-plugin-author]'), null);
assert.equal(row.textContent, 'Source unknown');
assert.equal(row.querySelectorAll('svg').length, 1, 'only the source outline icon remains');
dom.window.close();
}
});
test('mounted author rows load only when visible, retain fallbacks, cancel stale results and show a borderless themed maintainer pill', async () => {
@@ -58,12 +97,13 @@ test('mounted author rows load only when visible, retain fallbacks, cancel stale
});
};
try {
await render(); assert.equal(calls.length, 0); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
await render(); assert.equal(calls.length, 0); assert.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
await visible(); assert.deepEqual(calls[0], { kind: 'catalog', pluginId: entry.id, selectionToken: entry.selectionToken, sourceId: 'official' });
assert.ok(document.querySelector('[data-plugin-author-placeholder]'), 'loading uses the default placeholder');
assert.equal(document.querySelector('img'), null, 'loading does not reserve an empty avatar');
assert.equal(document.querySelector('[data-plugin-author]')?.textContent, 'Netcatty');
await act(async () => pending.shift()!(identity));
const image = document.querySelector('img')!;
assert.equal(image.getAttribute('src'), identity.avatar); assert.equal(image.width, 20); assert.equal(image.height, 20);
assert.equal(image.getAttribute('src'), identity.avatar); assert.equal(image.width, 16); assert.equal(image.height, 16);
const badge = document.querySelector<HTMLElement>('[data-plugin-official-maintainer]')!;
assert.equal(badge.textContent, 'Official maintainer'); assert.match(badge.title, /not a safety certification/);
assert.ok(badge.classList.contains('border-0'));
@@ -72,7 +112,7 @@ test('mounted author rows load only when visible, retain fallbacks, cancel stale
document.documentElement.classList.add('dark');
assert.ok(document.querySelector('[data-plugin-author-row]')?.classList.contains('items-center'));
await act(async () => image.dispatchEvent(new Event('error')));
assert.equal(document.querySelector('img'), null); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
assert.equal(document.querySelector('img'), null); assert.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
assert.ok(document.querySelector('[data-plugin-official-maintainer]'), 'image failure does not alter maintenance identity');
const next = { ...entry, selectionToken: 'c'.repeat(64) };
@@ -89,7 +129,7 @@ test('mounted author rows load only when visible, retain fallbacks, cancel stale
await render({ ...entry, selectionToken: 'f'.repeat(64) }); await visible();
await act(async () => pending.shift()!({ ...identity, avatar: null, officialMaintained: false }));
assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
assert.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
const installed = { id: entry.id, activeVersion: entry.version, archiveSha256: 'a'.repeat(64),
source: { id: 'official', kind: 'official', origin: 'https://plugins.netcatty.app', name: 'Official' },
manifest: entry.manifest, runtime: {} } as NetcattyInstalledPlugin;
@@ -52,7 +52,7 @@ test('information previews on hover/focus, pins on click and Escape returns focu
const entry = { id: 'com.example.guide', name: 'Guide', author: 'Example', version: '1.0.0', license: 'MIT', repository: 'example/plugin', commit: 'a'.repeat(40), description: 'Synthetic metadata',
manifest: { id: 'com.example.guide', name: 'Guide', publisher: 'Example', main: { browser: 'index.js' }, engines: { netcatty: '*', api: '0.1.0-internal' } },
} as NetcattyPluginCatalogEntry;
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readmeImages: {}, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
function Browser() {
const [selected, setSelected] = useState<string | null>(null);
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
+6 -6
View File
@@ -1,11 +1,11 @@
import React, { useRef, useState } from 'react';
import { Info } from 'lucide-react';
import { CircleAlert, Info } from 'lucide-react';
import { Button } from '../../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip';
/** Hover/focus previews explanatory text; click pins it in the existing accessible popover. */
export function PluginInfo({ label, children, badgeCount, previewContent }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode }) {
export function PluginInfo({ label, children, badgeCount, previewContent, variant = 'info' }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode; variant?: 'info' | 'error' }) {
const [pinned, setPinned] = useState(false);
const [preview, setPreview] = useState(false);
const suppressFocusPreview = useRef(false);
@@ -13,13 +13,13 @@ export function PluginInfo({ label, children, badgeCount, previewContent }: { la
<TooltipProvider delayDuration={200}>
<Tooltip open={!pinned && preview} onOpenChange={(open) => { if (!open || !suppressFocusPreview.current) setPreview(open); }}>
<TooltipTrigger asChild><PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="relative h-8 w-8 shrink-0 p-0 text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" aria-label={label} onBlur={() => { suppressFocusPreview.current = false; }} onPointerMove={() => { suppressFocusPreview.current = false; }}>
<Info size={15} />{Boolean(badgeCount) && <span aria-hidden="true" className="absolute right-0 top-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-muted px-0.5 text-[9px] font-medium leading-none text-foreground">{badgeCount}</span>}
<Button type="button" variant="ghost" size="sm" className={`relative shrink-0 p-0 focus-visible:ring-2 focus-visible:ring-ring ${variant === 'error' ? 'h-6 w-6 text-red-600 hover:bg-red-500/10 hover:text-red-600 dark:text-red-400 dark:hover:text-red-400' : 'h-8 w-8 text-muted-foreground'}`} aria-label={label} onBlur={() => { suppressFocusPreview.current = false; }} onPointerMove={() => { suppressFocusPreview.current = false; }}>
{variant === 'error' ? <CircleAlert size={15} /> : <Info size={15} />}{Boolean(badgeCount) && <span aria-hidden="true" className="absolute right-0 top-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-muted px-0.5 text-[9px] font-medium leading-none text-foreground">{badgeCount}</span>}
</Button>
</PopoverTrigger></TooltipTrigger>
<TooltipContent className="max-w-xs space-y-2 bg-popover p-3 text-xs leading-5 text-popover-foreground shadow-sm">{previewContent ?? children}</TooltipContent>
<TooltipContent className="max-h-64 max-w-xs space-y-2 overflow-y-auto break-words bg-popover p-3 text-xs leading-5 text-popover-foreground shadow-sm">{previewContent ?? children}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent onEscapeKeyDown={() => { suppressFocusPreview.current = true; }} aria-label={label} align="end" className="max-w-xs space-y-2 text-xs leading-5">{children}</PopoverContent>
<PopoverContent onEscapeKeyDown={() => { suppressFocusPreview.current = true; }} aria-label={label} align="end" className="max-h-64 max-w-xs space-y-2 overflow-y-auto break-words text-xs leading-5">{children}</PopoverContent>
</Popover>;
}
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
@@ -7,6 +8,12 @@ import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('installed grid filters, opens one detail and restores search, scroll and card focus without pill navigation', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousDom = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document })) {
previousDom.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const raf = globalThis.requestAnimationFrame;
@@ -34,10 +41,13 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.match(JSON.stringify(renderer.toJSON()), /Alpha safely formats synthetic greetings/);
const authorRow = alphaCard.findByProps({ 'data-plugin-author-row': true });
assert.ok(authorRow.findAllByType('span').some((node) => node.children.includes('Example')), 'author has its own compact row');
assert.ok(authorRow.findByProps({ 'data-plugin-author-placeholder': true }), 'an unverified installed publisher has a default avatar');
assert.equal(authorRow.findAllByType('img').length, 0, 'a publisher without a real avatar uses text only');
assert.equal(authorRow.findAllByProps({ 'data-plugin-author-placeholder': true }).length, 0);
assert.ok(authorRow.props.className.includes('gap-3'), 'author and source groups have 12px separation');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
assert.doesNotMatch(JSON.stringify(renderer.toJSON()), /Incompatible version/, 'error details stay hidden until requested');
assert.ok(renderer.root.findAllByType('button').some((node) => node.props['aria-label']?.includes('Beta') && node.props['aria-haspopup'] === 'dialog'), 'error icon opens details without navigating the card');
assert.ok(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')));
const pill = renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
await act(async () => pill.props.onClick());
@@ -61,5 +71,11 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.equal(unknown.length, 2);
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
} finally {
globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf;
dom.window.close();
for (const [key, descriptor] of previousDom) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -38,6 +38,12 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
trigger.focus();
await click(trigger);
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
// JSDOM cannot emulate Electron's native drag hit testing. Protect the
// no-drag boundary that keeps the portaled manager above the titlebar clickable.
const dialog = document.querySelector<HTMLElement>('[role="dialog"]')!;
assert.equal(dialog.classList.contains('app-no-drag'), true, 'plugin manager must exclude its content from native window dragging');
const visibleClose = dialog.querySelector<HTMLButtonElement>('button[data-dialog-close]:not([aria-hidden])')!;
assert.equal(visibleClose.closest('.app-no-drag'), dialog, 'the visible X must be inside the no-drag boundary');
assert.equal(mounts, 1);
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Plugin search');
assert.equal(document.querySelector('[role="dialog"]')?.contains(document.querySelector('[data-settings-scroll-pane]')), true);
@@ -60,20 +66,6 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.equal(unmounts, 2);
let handedOff = false;
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog>{(handoff) => <button onClick={() => handoff(() => {
handedOff = true;
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.notEqual(document.body.style.pointerEvents, 'none');
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
// Radix schedules close-focus cleanup after the React close commit. The
// settle inside click's act() can precede that commit; observe the actual
// handoff instead of assuming the cleanup timer already ran.
const handoffDeadline = Date.now() + 2_000;
while (!handedOff && Date.now() < handoffDeadline) await act(async () => { await settle(); });
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
const browse = useRef<HTMLButtonElement | null>(null);
@@ -98,6 +90,8 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
} finally {
await act(async () => root.unmount());
// Let Radix finish deferred focus cleanup before restoring the DOM globals.
await settle();
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useState } from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
@@ -10,41 +10,30 @@ export function PluginManagementDialog({ children, trigger, open: controlledOpen
onOpenChange?: (open: boolean) => void;
restoreFocusTo?: React.RefObject<HTMLElement | null>;
fallbackFocusTo?: React.RefObject<HTMLElement | null>;
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
children: React.ReactNode;
}) {
const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = (value: boolean) => { setInternalOpen(value); onOpenChange?.(value); };
const pendingHandoff = useRef<(() => void) | null>(null);
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger !== null && <DialogTrigger asChild>
{trigger ?? <Button variant="outline">{t('settings.plugins.openManager')}</Button>}
</DialogTrigger>}
<DialogContent onCloseAutoFocus={(event) => {
const action = pendingHandoff.current;
pendingHandoff.current = null;
if (action) {
const previous = restoreFocusTo?.current;
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
if (target?.isConnected) {
event.preventDefault();
// Wait for Radix to release modal pointer/focus ownership before opening
// the existing external plugin view host (including exit animations).
queueMicrotask(action);
} else {
const previous = restoreFocusTo?.current;
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
if (target?.isConnected) {
event.preventDefault();
target.focus({ preventScroll: true });
}
target.focus({ preventScroll: true });
}
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
}} className="app-no-drag flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<DialogTitle>{t('settings.plugins.title')}</DialogTitle>
</DialogHeader>
<div data-settings-scroll-pane className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 sm:p-6">
{typeof children === 'function' ? children(handoff) : children}
{children}
</div>
</DialogContent>
</Dialog>
@@ -28,7 +28,7 @@ const entries = ['Alpha', 'Beta'].map((name) => ({
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
})) as NetcattyPluginCatalogEntry[];
const catalog = {
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readmeImages: {}, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
} as ReturnType<typeof usePluginCatalog>;
@@ -103,7 +103,7 @@ export function PluginMarketplaceContent({ catalog, installed, selected, onSelec
<section><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></section>
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.readmeStatus === 'ready' && (catalog.readme?.trim() ? <PluginReadme markdown={catalog.readme} /> : <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.market.emptyReadme')}</p>)}
{catalog.readmeStatus === 'ready' && (catalog.readme?.trim() ? <PluginReadme markdown={catalog.readme} images={catalog.readmeImages} /> : <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.market.emptyReadme')}</p>)}
</div>
</article>}
</div>
@@ -3,7 +3,29 @@ import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageDetails } from './PluginPackageManager';
import { PluginPackageDetails, PluginPackageManager } from './PluginPackageManager';
import { JSDOM } from 'jsdom';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('installed cards and selected details keep author before compact source metadata', () => {
const plugin = { id: 'com.example.synthetic', enabled: false, activeVersion: '1.0.0',
source: { id: 'local', kind: 'local', name: 'Imported package', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.synthetic', name: 'Synthetic', publisher: 'Publisher', version: '1.0.0', engines: { netcatty: '*', api: '*' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: 'Diagnostic remains visible', quarantinedAt: null },
} as NetcattyInstalledPlugin;
const manager = { plugins: [plugin], available: true, statusKnown: true, busy: false } as ReturnType<typeof usePluginManager>;
for (const initialSelectedId of [null, plugin.id]) {
const dom = new JSDOM(renderToStaticMarkup(<I18nProvider locale="en"><PluginPackageManager manager={manager} initialSelectedId={initialSelectedId} /></I18nProvider>));
const article = dom.window.document.querySelector('article')!;
const row = article.querySelector('[data-plugin-author-row]')!;
assert.equal(row.querySelector('[data-plugin-source]')?.textContent, 'Local');
assert.match(row.textContent!, /PublisherLocal/);
assert.doesNotMatch(article.textContent!, /Source:/);
assert.doesNotMatch(article.textContent!, /Diagnostic remains visible/);
assert.ok(article.querySelector('button[aria-haspopup="dialog"][aria-label$="Synthetic"]'), 'recorded errors are available from the name-adjacent icon');
dom.window.close();
}
});
test('package detail renders manifest resources as text and explains advanced authority', () => {
const plugin = {
@@ -9,6 +9,8 @@ import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { PluginInfo } from './PluginInfo';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
const { t, resolvedLocale } = useI18n();
@@ -50,7 +52,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
</div>;
}
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId, detailActions }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void; detailActions?: React.ReactNode }) {
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
const [search, setSearch] = useState('');
@@ -77,6 +79,7 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => (!sourceFilter || (plugin.source?.id ?? 'local') === sourceFilter) && [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const [removing, setRemoving] = useState<string | null>(null);
const [clearSource, setClearSource] = useState(false);
const [uninstallError, setUninstallError] = useState<string | null>(null);
if (!manager.available) return null;
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
@@ -89,10 +92,18 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg focus:outline-none ${selectedPlugin ? 'space-y-4' : 'plugin-card flex min-h-[200px] flex-col gap-3 bg-card p-4'}`}>
<div className="flex flex-wrap items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
<div className="flex min-w-0 flex-1 items-center gap-1">
<button type="button" data-plugin-card={plugin.id} className="min-w-0 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
<div className="break-words text-sm font-medium">{nameOf(plugin)}</div>
</button>
{plugin.runtime.lastError && <PluginInfo variant="error" label={`${t('settings.plugins.manager.lastError')} — ${nameOf(plugin)}`}>
<p className="font-medium">{t('settings.plugins.manager.lastError')}</p>
<p className="whitespace-pre-wrap [overflow-wrap:anywhere]">{plugin.runtime.lastError}</p>
</PluginInfo>}
</div>
<div className="ml-auto flex shrink-0 items-center gap-2" data-plugin-header-actions="">
{selected === plugin.id && detailActions}
<Button variant="ghost" size="sm" type="button"
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${manager.statusKnown !== false && plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
style={manager.statusKnown !== false && plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
@@ -102,24 +113,39 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
disabled={manager.busy || manager.statusKnown === false} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
</Button>
</div>
</div>
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
<PluginAuthorRow plugin={plugin} showSource />
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => choose(plugin.id)} aria-label={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`}>{t('settings.plugins.manager.details')}</Button>
</div> : <p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>}
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearSource} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id, clearSource); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => { setClearSource(false); setRemoving(plugin.id); }}>{t('settings.plugins.manager.remove')}</Button>}
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }}
actions={<Dialog open={removing === plugin.id} onOpenChange={(open) => {
if (manager.busy) return;
setRemoving(open ? plugin.id : null);
if (open) { setClearSource(false); setUninstallError(null); }
}}>
<DialogTrigger asChild><Button variant="destructive" size="sm" disabled={manager.busy}>{t('settings.plugins.manager.remove')}</Button></DialogTrigger>
<DialogContent hideCloseButton={manager.busy} className="max-h-[85dvh] w-[calc(100vw-2rem)] max-w-md overflow-y-auto" data-plugin-uninstall-confirm="">
<DialogHeader className="pr-6"><DialogTitle>{t('settings.plugins.manager.remove')} · {nameOf(plugin)}</DialogTitle></DialogHeader>
<p className="text-sm text-muted-foreground">{t('settings.plugins.manager.removeNote')}</p>
<label className="flex items-start gap-2 text-sm"><input className="mt-1" type="checkbox" checked={clearSource} disabled={manager.busy} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
{uninstallError && <p role="alert" className="break-words text-sm text-destructive">{uninstallError}</p>}
<DialogFooter>
<Button variant="outline" disabled={manager.busy} onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
<Button variant="destructive" disabled={manager.busy} aria-busy={manager.busy} onClick={async () => {
setUninstallError(null);
try { await manager.uninstall(plugin.id, clearSource); choose(null); }
catch (cause) { setUninstallError(cause instanceof Error ? cause.message : String(cause)); }
}}>{t('settings.plugins.manager.remove')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>}
/>
</>}
</article>)}
</div>
@@ -7,9 +7,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { SettingRow } from '../settings-ui';
/** Host UI only. Plugin views cannot query or mutate this security state. */
export function PluginPermissionPanel({ pluginId, onChanged }: {
export function PluginPermissionPanel({ pluginId, onChanged, actions }: {
pluginId: string;
onChanged: () => Promise<void>;
actions?: React.ReactNode;
}) {
const { t, resolvedLocale } = useI18n();
const [snapshot, setSnapshot] = useState<NetcattyPluginPermissionSnapshot | null>(null);
@@ -102,7 +103,10 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
</li>)}</ol>
</details>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
<Button variant="destructive" size="sm" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>
</>}
<div className="flex flex-wrap items-center gap-2">
{snapshot && <Button variant="secondary" size="sm" className="bg-amber-500 text-amber-950 hover:bg-amber-400" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>}
{actions}
</div>
</section>;
}
+10 -1
View File
@@ -1,6 +1,6 @@
import React from 'react';
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { PluginReadme } from './PluginReadme';
@@ -12,3 +12,12 @@ test('README never creates active HTML, remote media or script links', () => {
assert.match(html, /rel="noopener noreferrer"/);
assert.match(html, /&lt;script&gt;/);
});
test('README only renders registered local rasterized images and placeholders otherwise', () => {
const path = `/assets/${'a'.repeat(64)}.png`;
const html = renderToStaticMarkup(<PluginReadme markdown={`![ok](${path}) ![tracker](https://evil.test/x) ![bad](/unknown)`} images={{ [path]: 'data:image/png;base64,YQ==' }} />);
assert.match(html, /src="data:image\/png;base64,YQ=="/);
assert.match(html, /\[tracker\]/);
assert.match(html, /\[bad\]/);
assert.doesNotMatch(html, /evil\.test/);
});
+11 -6
View File
@@ -2,9 +2,9 @@ import React, { useMemo } from 'react';
import { fromMarkdown } from 'mdast-util-from-markdown';
import type { RootContent, Root } from 'mdast';
function renderNode(node: Root | RootContent, key: number, depth = 0): React.ReactNode {
function renderNode(node: Root | RootContent, key: number, images: Record<string, string>, depth = 0): React.ReactNode {
if (depth > 24) return null;
const children = 'children' in node ? node.children.map((child, index) => renderNode(child as RootContent, index, depth + 1)) : null;
const children = 'children' in node ? node.children.map((child, index) => renderNode(child as RootContent, index, images, depth + 1)) : null;
switch (node.type) {
case 'root': return <React.Fragment key={key}>{children}</React.Fragment>;
case 'heading': return <h4 key={key} className="mt-5 font-semibold">{children}</h4>;
@@ -24,14 +24,19 @@ function renderNode(node: Root | RootContent, key: number, depth = 0): React.Rea
try { const url = new URL(node.url); safe = url.protocol === 'https:' && !url.username && !url.password; } catch { /* relative and unsafe links stay text */ }
return safe ? <a key={key} href={node.url} target="_blank" rel="noopener noreferrer" className="underline">{children}</a> : <span key={key}>{children}</span>;
}
// Never embed HTML, media, SVG, iframes, remote images, or reference URLs.
case 'image': return <span key={key} className="text-muted-foreground">[{node.alt ?? 'image'}]</span>;
case 'image': {
const src = Object.hasOwn(images, node.url) ? images[node.url] : undefined;
return src && /^data:image\/png;base64,[A-Za-z0-9+/]+=*$/.test(src)
? <img key={key} src={src} alt={node.alt ?? ''} className="my-2 max-w-full" />
: <span key={key} className="text-muted-foreground">[{node.alt ?? 'image'}]</span>;
}
case 'imageReference': return <span key={key} className="text-muted-foreground">[{node.alt ?? 'image'}]</span>;
case 'html': return <span key={key}>{node.value}</span>;
default: return <React.Fragment key={key}>{children}</React.Fragment>;
}
}
export function PluginReadme({ markdown }: { markdown: string }) {
export function PluginReadme({ markdown, images = {} }: { markdown: string; images?: Record<string, string> }) {
const tree = useMemo(() => fromMarkdown(markdown.slice(0, 256 * 1024)), [markdown]);
return <div className="break-words text-sm">{renderNode(tree, 0)}</div>;
return <div className="break-words text-sm">{renderNode(tree, 0, images)}</div>;
}
@@ -12,21 +12,23 @@ async function fixture() {
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
install('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
install('cancelAnimationFrame', (id: number) => frames.delete(id));
const scrolled: HTMLElement[] = [];
dom.window.HTMLElement.prototype.scrollIntoView = function () { scrolled.push(this); };
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const { OPEN_PLUGIN_VIEW_EVENT } = await import('../../../application/state/usePluginViewLifecycle');
const viewRequests: unknown[] = [];
dom.window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, (event) => viewRequests.push((event as CustomEvent).detail));
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
let savedValue = 'synthetic';
let rejectSave = false;
let installed = plugin;
let delayed = false;
const pending: (() => void)[] = [];
@@ -38,12 +40,18 @@ async function fixture() {
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions(query: NetcattyPluginContributionQuery) {
if (delayed && query.scopeIds) await new Promise<void>((resolve) => pending.push(resolve));
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] };
if (!installed.enabled) return { locale: 'en', plugins: [] };
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [{ id: 'com.example.alpha.page', title: 'Ordinary page', location: 'tab', visible: true }], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: savedValue, group: 'General', visible: true, configured: true }] }] };
},
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
async updatePluginSetting(_pluginId: string, _settingId: string, value: string) {
if (rejectSave) throw new Error('Could not save');
savedValue = value; return { restartRequired: false };
},
async resetPluginSetting() { savedValue = 'synthetic'; return { restartRequired: false }; },
} });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
@@ -51,99 +59,107 @@ async function fixture() {
await perform(() => root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>));
const gear = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(gear && !gear.disabled);
const flushFrames = async (count = 3) => {
for (let i = 0; i < count; i++) await act(async () => {
const current = [...frames.values()]; frames.clear();
for (const callback of current) callback(i * 16);
});
};
return {
gear, scrolled, perform, flushFrames,
section: () => document.querySelector<HTMLElement>('section[aria-labelledby="plugin-settings-com.example.alpha"]'),
gear, perform, viewRequests,
rejectSave: (value: boolean) => { rejectSave = value; },
section: () => document.querySelector<HTMLElement>('[data-plugin-settings-form]'),
open: async (slow = false) => { delayed = slow; await perform(() => { gear.focus(); gear.click(); }); },
release: async () => { delayed = false; await perform(() => { for (const resolve of pending.splice(0)) resolve(); }); },
selectTab: async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
await perform(() => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
},
close: async () => { await perform(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); },
replaceOwner: async () => { installed = { ...plugin, source: { ...plugin.source, id: 'third-party:replacement', kind: 'third-party' } }; await perform(() => window.dispatchEvent(new Event('netcatty:plugin-packages-changed'))); },
disable: async () => { installed = { ...installed, enabled: false }; await perform(() => { for (const callback of [...listeners]) callback(); }); },
refresh: async () => { await perform(() => { for (const callback of [...listeners]) callback(); }); },
dispose: async () => {
await act(async () => root.unmount()); dom.window.close();
await act(async () => { root.unmount(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
},
};
}
test('gear entry scrolls and focuses its settings once, after dialog autofocus', async () => {
test('gear opens a dedicated settings modal without management chrome and restores focus', async () => {
const f = await fixture();
try {
await f.open();
assert.notEqual(document.activeElement, f.section());
await f.flushFrames();
assert.deepEqual(f.scrolled, [f.section()]);
assert.equal(document.activeElement, f.section());
assert.ok(document.querySelector('[data-plugin-settings-dialog="com.example.alpha"]'));
assert.equal(document.querySelector('[data-plugin-detail]'), null);
assert.equal(document.querySelector('[role=dialog] [role=tablist]'), null);
const input = document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
input.focus();
await f.refresh(); await f.flushFrames();
assert.equal(document.activeElement, input, 'later contribution refresh cannot steal focus');
assert.equal(f.scrolled.length, 1);
input.focus(); await f.refresh();
assert.equal(document.activeElement, input, 'refresh cannot steal focus');
await f.close();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
assert.deepEqual(f.viewRequests, [], 'settings never open custom plugin pages');
} finally { await f.dispose(); }
});
test('gear entry waits for slow contributions before targeting the existing settings section', async () => {
test('settings modal saves across reopening, reports failures and resets', async () => {
const f = await fixture();
try {
await f.open(true); await f.flushFrames();
assert.equal(f.section(), null); assert.equal(f.scrolled.length, 0);
await f.release(); await f.flushFrames();
assert.equal(f.scrolled.length, 1); assert.equal(document.activeElement, f.section());
await f.open();
const section = f.section()!;
assert.equal(document.querySelector('[data-host-security-surface]'), null);
assert.equal(section.querySelector('h4')?.textContent, 'General');
assert.doesNotMatch(section.textContent!, /com\.example\.alpha|Ordinary page/);
assert.equal(section.querySelector('[data-plugin-remote-surface]'), null);
const input = () => document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
const edit = async (value: string) => {
await f.perform(() => {
input().focus();
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input(), value);
input().dispatchEvent(new Event('input', { bubbles: true }));
input().blur();
});
};
await edit('Saved preference');
await f.close(); await f.open();
assert.equal(input().value, 'Saved preference');
f.rejectSave(true); await edit('Rejected preference');
assert.match(f.section()!.querySelector('[role=alert]')!.textContent!, /Could not save/);
f.rejectSave(false);
await f.close(); await f.open();
assert.equal(input().value, 'Saved preference', 'failed save did not replace the stored value');
await f.perform(() => [...f.section()!.querySelectorAll<HTMLButtonElement>('button')].find(button => button.getAttribute('aria-label') === 'Reset')!.click());
assert.equal(input().value, 'synthetic');
assert.deepEqual(f.viewRequests, [], 'settings never open custom plugin pages');
} finally { await f.dispose(); }
});
test('closing between animation frames cancels an already scheduled gear focus', async () => {
test('a slow settings response renders in the modal and never reopens a dismissed modal', async () => {
const f = await fixture();
try {
await f.open(); await f.flushFrames(1);
await f.close(); await f.flushFrames();
assert.equal(f.scrolled.length, 0);
await f.open(true);
assert.ok(document.querySelector('[role=dialog] [role=status]'));
await f.release();
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
await f.close(); await f.open(true); await f.close(); await f.release();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
for (const cancellation of ['tab', 'plugin', 'owner', 'close']) test(`pending gear entry cancels on ${cancellation} change`, async () => {
test('removing settings contributions closes the open modal', async () => {
const f = await fixture();
try {
await f.open(true);
if (cancellation === 'tab') await f.selectTab('Official');
if (cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
if (cancellation === 'owner') await f.replaceOwner();
if (cancellation === 'close') await f.close();
await f.release();
if (cancellation === 'tab') await f.selectTab('Installed');
if (cancellation === 'tab' || cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.equal(f.scrolled.length, 0, 'late data or returning to the plugin cannot revive the cancelled intent');
if (cancellation === 'close') {
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
}
await f.open(); await f.disable();
assert.equal(document.querySelector('[data-plugin-settings-dialog]'), null);
} finally { await f.dispose(); }
});
test('normal Browse and detail entry retain their existing detail focus', async () => {
test('plugin details offer the same modal and Escape returns to the detail first', async () => {
const f = await fixture();
try {
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Browse')!;
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Browse')!;
await f.perform(() => { browse.focus(); browse.click(); });
await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.ok(f.section()); assert.equal(f.scrolled.length, 0);
assert.equal(document.activeElement, document.querySelector('[data-plugin-detail]'));
assert.equal(f.section(), null, 'settings are not embedded below management content');
const gear = document.querySelector<HTMLButtonElement>('[data-plugin-detail] button[aria-label="Settings for Alpha"]')!;
assert.ok(gear);
await f.perform(() => { gear.focus(); gear.click(); });
assert.ok(f.section());
await f.close();
assert.equal(f.section(), null);
assert.ok(document.querySelector('[data-plugin-detail]'));
assert.equal(document.activeElement, gear);
await f.close(); assert.equal(document.activeElement, browse);
} finally { await f.dispose(); }
});
@@ -42,9 +42,23 @@ test('landing rows expose real sources/status/settings and quick toggles preserv
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
assert.equal(document.querySelectorAll('[role=switch]').length, 2, 'only real plugin toggles appear on landing');
assert.match(document.body.textContent!, /Running/);
assert.match(document.body.textContent!, /Source: Official/);
assert.match(document.body.textContent!, /Synthetic source/);
const installedSection = () => document.querySelectorAll('section')[1];
assert.doesNotMatch(installedSection().textContent!, /Running|Disabled|Source:/);
const sourceRows = [...document.querySelectorAll('[data-plugin-author-row]')];
assert.equal(sourceRows[0].querySelector('[data-plugin-source]')?.textContent, 'Official');
const content = sourceRows[0].parentElement!;
assert.ok(content.classList.contains('gap-1'), 'title, metadata and description have 4px vertical gaps');
assert.ok(content.classList.contains('flex-col'));
assert.ok(content.classList.contains('min-w-0'));
assert.equal(content.firstElementChild?.querySelector('[title="Alpha"]')?.textContent, 'Alpha');
assert.ok(content.firstElementChild?.classList.contains('text-sm'), 'title remains 14px');
assert.equal(content.lastElementChild?.textContent, 'Synthetic description');
assert.ok(content.classList.contains('text-xs'), 'metadata and description remain 12px');
assert.ok(!content.parentElement?.classList.contains('mt-0.5'), 'shared SettingRow margin does not compress the local stack');
const thirdParty = sourceRows[1].querySelector<HTMLElement>('[data-plugin-source]')!;
assert.equal(thirdParty.textContent, 'Third-party');
assert.match(thirdParty.title, /Synthetic source.*synthetic\.example\.com/);
assert.equal(thirdParty.getAttribute('aria-label'), thirdParty.title);
assert.doesNotMatch(document.body.textContent!, /Save host audit logs locally|trust all|safe mode|telemetry/i);
await act(async () => button('Browse').click());
await act(async () => byLabel('Settings for Alpha').click());
@@ -63,6 +77,15 @@ test('landing rows expose real sources/status/settings and quick toggles preserv
assert.equal(changes.length, 1);
await render(manager, new Set());
assert.equal(byLabel('Settings for Alpha').disabled, true, 'unavailable settings cannot open an empty destination');
for (const [status, label] of [['starting', 'Starting'], ['stopping', 'Stopping'], ['error', 'Error'], ['quarantined', 'Quarantined'], ['unexpected', 'Status unknown']]) {
await render({ ...manager, plugins: [{ ...plugins[0], runtime: { ...plugins[0].runtime, status } }] });
assert.ok([...document.querySelectorAll('[role=status]')].some((item) => item.textContent === label), `${status} remains visible`);
}
for (const enabled of [true, false]) {
await render({ ...manager, plugins: [{ ...plugins[0], enabled, runtime: { ...plugins[0].runtime, status: 'stopped' } }] });
assert.equal(installedSection().querySelectorAll('[role=status]').length, 0, 'normal stopped/idle state is conveyed by the toggle');
assert.doesNotMatch(installedSection().textContent!, /Enabled, idle|Disabled|Stopped/);
}
await render({ ...manager, plugins: [] });
assert.match(document.body.textContent!, /No plugins installed/);
await render({ ...manager, plugins: [], statusKnown: false });
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from 'react';
import { RefreshCw, Settings2 } from 'lucide-react';
import { RefreshCw, Settings } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import type { usePluginManager } from '../../../application/state/usePluginManager';
@@ -31,15 +31,9 @@ export function PluginSettingsLanding({ manager, settingsAvailable, settingsErro
const statusOf = (plugin: NetcattyInstalledPlugin) => {
if (!manager.statusKnown) return t('settings.plugins.permissions.statusUnknown');
const state = plugin.runtime.status;
if (['running', 'starting', 'stopping', 'error', 'quarantined'].includes(state)) return t(`settings.plugins.landing.status.${state}`);
if (state !== 'stopped') return t('settings.plugins.landing.status.unknown');
return t(plugin.enabled ? 'settings.plugins.landing.status.idle' : 'settings.plugins.manager.disabled');
};
const sourceOf = (plugin: NetcattyInstalledPlugin) => {
const source = plugin.source;
if (!source) return t('settings.plugins.landing.sourceUnknown');
const kind = t(source.kind === 'official' ? 'settings.plugins.sources.official' : source.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local');
return `${t('settings.plugins.sources.source')}: ${kind}${source.kind === 'third-party' ? ` · ${source.name}` : ''}`;
if (['starting', 'stopping', 'error', 'quarantined'].includes(state)) return t(`settings.plugins.landing.status.${state}`);
if (state !== 'stopped' && state !== 'running') return t('settings.plugins.landing.status.unknown');
return null;
};
return <>
<SettingsSection title={t('settings.plugins.landing.options')}>
@@ -67,19 +61,22 @@ export function PluginSettingsLanding({ manager, settingsAvailable, settingsErro
{visible.map((plugin) => {
const name = nameOf(plugin);
const description = localized(plugin.manifest?.description);
const status = statusOf(plugin);
const hasError = manager.statusKnown && ['error', 'quarantined'].includes(plugin.runtime.status);
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} label={<span className="flex min-w-0 items-baseline gap-2">
<span className="truncate" title={name}>{name}</span>
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>} description={<>
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
<span className="block break-words">{[sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length);
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={40} />} description={<div className="flex min-w-0 flex-col gap-1 text-xs text-muted-foreground">
<span className="flex min-w-0 items-center gap-2 text-sm font-medium text-foreground">
<span className="truncate" title={name}>{name}</span>
{status && <span role="status" className={`inline-flex h-5 shrink-0 items-center justify-center rounded-full px-2 text-[10px] font-medium leading-4 ${hasError ? 'bg-red-500/15 text-red-600 dark:text-red-400' : 'bg-muted text-muted-foreground'}`}>{status}</span>}
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>
<PluginAuthorRow plugin={plugin} showSource />
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
{description && <span className="line-clamp-2 break-words">{description}</span>}
</>}>
<div className="flex items-center gap-3">
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
</div>}>
<div className="flex shrink-0 items-center gap-2">
{declaredSettings && <Button className="h-8 w-8 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings size={15} /></Button>}
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
</div>
</SettingRow>;
@@ -5,8 +5,8 @@ import { createRequire } from 'node:module';
import { JSDOM } from 'jsdom';
const require = createRequire(import.meta.url);
test('real Settings landing leaves views visible while idle; log and Ask dialogs suspend and restore them', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="native-view-mount"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
test('real Settings logging and Ask dialogs open, nest and dismiss correctly', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
@@ -22,7 +22,6 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { PluginPermissionHost } = await import('../../plugins/PluginPermissionHost');
const { observePluginHostSecurityVisibility } = await import('../../../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../../../electron/plugins/permissionDecisionHost.cjs');
type Prompt = NetcattyPluginPermissionPrompt | { requestId: string; closed: true };
const listeners = new Set<(event: Prompt) => void>();
@@ -39,9 +38,6 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
} });
const manager = { plugins: [], available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true,
async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let visible = true;
const transitions: boolean[] = [];
const stopVisibility = observePluginHostSecurityVisibility(document.getElementById('native-view-mount')!, (next) => { visible = next; transitions.push(next); });
const root = createRoot(document.getElementById('root')!);
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((element) => element.textContent === label)!;
const flushVisibility = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
@@ -50,24 +46,19 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.ok(button('Configure'), 'real idle logging row is present');
assert.equal(visible, true, 'idle Settings landing must not hide the native child');
assert.deepEqual(transitions, []);
await act(async () => button('Configure').click());
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'actual logging security dialog must hide the child');
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[0].click());
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[1].click());
await flushVisibility();
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
assert.equal(visible, false, 'the verbose security confirmation must also keep the child hidden');
await act(async () => button('Cancel').click());
await flushVisibility();
assert.equal(visible, false, 'cancelling verbose returns to the protected logging dialog');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
await act(async () => button('Close').click());
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(visible, true, 'closing logs must restore the child while its idle row remains');
let pending!: Promise<{ decision: string }>;
await act(async () => {
pending = host.request({ requestId: 'settings-visibility-ask', pluginId: 'com.example.settings-visibility', pluginName: 'Settings visibility fixture', pluginVersion: '1.0.0',
@@ -75,14 +66,12 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
});
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'Ask must still hide the child above the real landing');
await act(async () => { button('Deny').click(); await pending; });
await flushVisibility();
assert.equal((await pending).decision, 'deny');
assert.equal(visible, true, 'dismissing Ask must restore the same child');
assert.deepEqual(transitions, [false, true, false, true]);
assert.equal(document.querySelector('[role="dialog"]'), null);
} finally {
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); dom.window.close();
await act(async () => root.unmount()); host.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('uninstall requires a visible confirmation, reports failures, and removes the installed card after success', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'DocumentFragment', 'CustomEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { usePluginManager } = await import('../../../application/state/usePluginManager');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const { PluginPackageManager } = await import('./PluginPackageManager');
const plugin = {
id: 'com.example.disposable', enabled: false, activeVersion: '1.0.0',
manifest: { manifestVersion: 1, id: 'com.example.disposable', name: 'Disposable', version: '1.0.0', publisher: 'Example', engines: { netcatty: '*', api: '*' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
} as NetcattyInstalledPlugin;
let installed = [plugin];
const removed: string[] = [];
let finish!: () => void;
let fail!: (error: Error) => void;
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async listPlugins() { return installed; },
async getPluginPermissions() { return { policies: [], grants: [], audit: [] }; },
async uninstallPlugin(id: string) {
removed.push(id);
await new Promise<void>((resolve, reject) => { finish = resolve; fail = reject; });
installed = installed.filter((entry) => entry.id !== id);
},
} });
function Fixture() {
const manager = usePluginManager();
return <PluginManagementDialog open trigger={null}><PluginPackageManager manager={manager} initialSelectedId={plugin.id} /></PluginManagementDialog>;
}
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
const confirmation = () => document.querySelector<HTMLElement>('[data-plugin-uninstall-confirm]');
const button = (label: string, within: ParentNode = document) => [...within.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
try {
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
const trigger = button('Uninstall');
await act(async () => { trigger.click(); await settle(); });
assert.ok(confirmation(), 'confirmation is a separate dialog, not below the scroll fold');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
assert.deepEqual(removed, [], 'opening the confirmation never deletes the plugin');
const checkbox = confirmation()!.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
assert.equal(checkbox.checked, false);
await act(async () => { checkbox.click(); button('Cancel', confirmation()!).click(); await settle(); });
assert.equal(confirmation(), null);
assert.deepEqual(removed, []);
assert.equal(document.activeElement, trigger);
await act(async () => { trigger.click(); await settle(); });
assert.equal(confirmation()!.querySelector<HTMLInputElement>('input')!.checked, false, 'private data clearing is reset for a new confirmation');
await act(async () => { button('Uninstall', confirmation()!).click(); await settle(); });
assert.deepEqual(removed, [plugin.id]);
assert.equal(button('Uninstall', confirmation()!).disabled, true);
await act(async () => { fail(new Error('Synthetic removal failed')); await settle(); });
assert.match(confirmation()!.querySelector('[role="alert"]')!.textContent!, /Synthetic removal failed/);
assert.equal(installed.length, 1);
await act(async () => { button('Uninstall', confirmation()!).click(); await settle(); });
await act(async () => { finish(); await settle(); });
assert.equal(confirmation(), null);
assert.equal(installed.length, 0);
assert.equal(document.querySelector('article'), null, 'the removed plugin no longer has an installed card or detail');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1, 'the plugin manager remains open');
} finally {
await act(async () => { root.unmount(); await settle(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
+28 -28
View File
@@ -1260,6 +1260,34 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</TabsContent>
<TabsContent value="tools" className="m-0 space-y-6">
<SettingsSection
anchorId="ai-tool-access-mode"
title={t('ai.toolAccess.title')}
>
<SettingCard>
<SettingRow className="flex-col items-start md:flex-row md:items-center" description={t('ai.toolAccess.description')}>
<div className="flex flex-wrap justify-end gap-2">
<Select
value={toolIntegrationMode}
options={[
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
]}
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
className="w-48"
/>
<ToolAccessGuidance mode={toolIntegrationMode} />
</div>
</SettingRow>
</SettingCard>
</SettingsSection>
<ExternalMcpCard />
<AIPrivacySection />
<SettingsSection title={t('ai.chatShortcuts.title')}>
<SettingCard divided>
<SettingRow
@@ -1276,34 +1304,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</SettingCard>
</SettingsSection>
<SettingsSection
anchorId="ai-tool-access-mode"
title={t('ai.toolAccess.title')}
>
<SettingCard>
<SettingRow description={t('ai.toolAccess.description')}>
<Select
value={toolIntegrationMode}
options={[
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
]}
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
className="w-48"
/>
</SettingRow>
<div className="pb-4">
<ToolAccessGuidance mode={toolIntegrationMode} />
</div>
</SettingCard>
</SettingsSection>
<AIPrivacySection />
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
<ExternalMcpCard />
</SettingsSection>
<SettingsSection
anchorId="ai-user-skills"
title={t('ai.userSkills.title')}
@@ -48,18 +48,21 @@ test('landing settings entry targets the declared plugin and a manager tab round
assert.ok(settings);
assert.equal(settings.disabled, false);
await act(async () => { settings.focus(); settings.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-detail]'));
assert.ok(document.querySelector('[data-plugin-settings-dialog]'));
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Browse')!;
await act(async () => { browse.focus(); browse.click(); await settle(); });
await selectTab('Official');
await selectTab('Installed');
assert.equal(document.querySelector('[data-plugin-detail]'), null, 'returning to Installed shows the grid, not a stale deep-link detail');
assert.equal(document.querySelector('input[aria-label="Alpha setting"]'), null);
const card = document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!;
await act(async () => { card.click(); await settle(); });
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'), 'choosing the plugin again restores its real settings');
assert.ok(document.querySelector('[data-plugin-detail] button[aria-label="Settings for Alpha"]'), 'details offer the same settings entry');
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, settings);
assert.equal(document.activeElement, browse);
const toggle = document.querySelector<HTMLButtonElement>('button[aria-label="Disable Alpha"]')!;
await act(async () => { toggle.click(); await settle(); });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic management failure/);
@@ -4,7 +4,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginSettingField } from './SettingsPluginsTab';
import { PluginSettingField } from '../../plugins/PluginSettingsForm';
test('plugin setting cards retain localized descriptions alongside structured controls', () => {
const setting = {
+14 -387
View File
@@ -7,342 +7,25 @@ import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import { PluginSettingsLanding } from './PluginSettingsLanding';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { SettingsAnchor, SettingsTabContent } from '../settings-ui';
import { requestOpenPluginView } from '../../plugins/PluginContributionHost';
import { parsePluginStructuredSettingValue } from './pluginSettingValues';
import { PluginStructuredSettingEditor } from './PluginStructuredSettingEditor';
import { useAvailableFonts } from '../../../application/state/fontStore';
import { TerminalFontSelect } from '../TerminalFontSelect';
import {
resolvePluginSettingScopeSelection,
usePluginSettingScopeCatalog,
} from '../../../application/state/usePluginSettingScopeCatalog';
import { PluginContributionIcon } from '../../plugins/PluginContributionIcon';
import { PluginSettingsEntry, PluginSettingsDialog } from '../../plugins/PluginSettingsEntry';
export function PluginSettingField({
pluginId,
setting,
updateSetting,
resetSetting,
selectSettingPath,
availableFonts,
}: {
pluginId: string;
setting: NetcattyPluginSettingContribution;
updateSetting: ReturnType<typeof usePluginContributions>['updateSetting'];
resetSetting: ReturnType<typeof usePluginContributions>['resetSetting'];
selectSettingPath: ReturnType<typeof usePluginContributions>['selectSettingPath'];
availableFonts: ReturnType<typeof useAvailableFonts>;
}) {
const { t } = useI18n();
const [value, setValue] = useState<unknown>(setting.secret ? '' : setting.value ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const itemLabel = (key: 'settings.plugins.moveItemUp' | 'settings.plugins.moveItemDown', index: number) => (
t(key).replace('{label}', setting.label).replace('{index}', String(index + 1))
);
useEffect(() => {
setValue(setting.secret ? '' : setting.value ?? '');
}, [setting.secret, setting.value]);
const save = async (nextValue: unknown) => {
setSaving(true);
setError(null);
try {
await updateSetting(pluginId, setting.id, nextValue, setting.scopeId ?? undefined);
if (setting.secret) setValue('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const control = (() => {
if (setting.scopeId == null) {
return <div className="text-xs text-muted-foreground">{t('settings.plugins.scopeContext').replace('{scope}', setting.scope)}</div>;
}
if (setting.control === 'switch') {
return (
<input
aria-label={setting.label}
type="checkbox"
checked={Boolean(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.checked); void save(event.target.checked); }}
className="h-4 w-4 accent-primary"
/>
);
}
if (setting.control === 'radio') {
return (
<fieldset className="space-y-2" disabled={saving}>
<legend className="sr-only">{setting.label}</legend>
{setting.options?.map((option) => (
<label key={option.value} className="flex items-start gap-2 text-sm">
<input
type="radio"
name={setting.id}
value={option.value}
checked={value === option.value}
onChange={() => { setValue(option.value); void save(option.value); }}
className="mt-0.5 accent-primary"
/>
<span>{option.label}{option.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}</span>
</label>
))}
</fieldset>
);
}
if (setting.control === 'select') {
return (
<select
aria-label={setting.label}
value={String(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.value); void save(event.target.value); }}
className="h-9 min-w-52 rounded-md border border-input bg-background px-3 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'multiselect') {
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<select
aria-label={setting.label}
multiple
value={selected}
disabled={saving}
onChange={(event) => {
const next = [...event.currentTarget.selectedOptions].map((option) => option.value);
setValue(next);
void save(next);
}}
className="min-h-24 min-w-52 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'textarea') {
return (
<textarea
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => void save(event.currentTarget.value)}
className="min-h-24 w-full max-w-xl rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
);
}
if (setting.control === 'number' || setting.control === 'slider') {
return (
<Input
aria-label={setting.label}
type={setting.control === 'slider' ? 'range' : 'number'}
value={typeof value === 'number' ? value : Number(value) || 0}
min={setting.minimum}
max={setting.maximum}
step={setting.step}
disabled={saving}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => void save(Number(event.currentTarget.value))}
className="max-w-sm"
/>
);
}
if (setting.control === 'list' || setting.control === 'table') {
return (
<PluginStructuredSettingEditor
setting={setting}
value={typeof value === 'string' ? parsePluginStructuredSettingValue(value) : value}
disabled={saving}
onChange={setValue}
onCommit={(next) => void save(next)}
labels={{
add: t('settings.plugins.addItem'),
remove: t('settings.plugins.removeItem'),
moveUp: (index) => itemLabel('settings.plugins.moveItemUp', index),
moveDown: (index) => itemLabel('settings.plugins.moveItemDown', index),
}}
/>
);
}
if (setting.control === 'font') {
return (
<TerminalFontSelect
value={String(value)}
fonts={availableFonts}
disabled={saving}
onChange={(next) => { setValue(next); void save(next); }}
className="w-full max-w-xl"
ariaLabel={setting.label}
/>
);
}
if (setting.control === 'file' || setting.control === 'directory') {
return (
<div className="flex max-w-xl gap-2">
<Input aria-label={setting.label} value={String(value)} readOnly disabled={saving} className="min-w-0 flex-1" />
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => void selectSettingPath(setting.control as 'file' | 'directory', setting.label, String(value || '')).then((selected) => {
if (!selected) return;
setValue(selected);
void save(selected);
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))}
>
<FolderOpen size={14} className="mr-2" /> {t('settings.plugins.browse')}
</Button>
</div>
);
}
if (setting.control === 'keybinding') {
return (
<Input
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder ?? t('settings.plugins.pressKeybinding')}
readOnly
disabled={saving}
onKeyDown={(event) => {
event.preventDefault();
const key = normalizePluginKeyboardEvent(event.nativeEvent);
if (!key) return;
setValue(key);
void save(key);
}}
className="max-w-xl"
/>
);
}
return (
<Input
aria-label={setting.label}
type={setting.secret || setting.control === 'password' ? 'password' : setting.control === 'color' ? 'color' : 'text'}
value={String(value)}
placeholder={setting.secret && setting.configured ? t('settings.plugins.configuredReplacement') : setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => {
const next = event.currentTarget.value;
if (!setting.secret || next.length > 0) void save(next);
}}
className="max-w-xl"
/>
);
})();
return (
<div className="rounded-lg border border-border/70 bg-background p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<label className="text-sm font-medium">{setting.label}</label>
{setting.description && (
<p className="mt-1 max-w-2xl text-xs leading-relaxed text-muted-foreground">{setting.description}</p>
)}
<p className="mt-1 font-mono text-[10px] text-muted-foreground">{setting.id} · {setting.scope}</p>
</div>
{setting.restartRequired && <span className="rounded bg-amber-500/15 px-2 py-1 text-[10px] text-amber-700 dark:text-amber-300">{t('settings.plugins.restartRequired')}</span>}
</div>
{control}
<div className="flex items-center gap-2">
{!setting.required && setting.scopeId != null && (
<Button
type="button"
variant="outline"
size="sm"
disabled={saving}
onClick={() => {
setSaving(true);
setError(null);
void resetSetting(pluginId, setting.id, setting.scopeId ?? undefined)
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setSaving(false));
}}
>
{t('common.reset')}
</Button>
)}
{saving && <span className="text-xs text-muted-foreground">{t('settings.plugins.saving')}</span>}
{setting.secret && setting.configured && <span className="text-xs text-emerald-600">{t('settings.plugins.storedSecurely')}</span>}
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
</div>
</div>
);
}
function PluginManagementContent({ handoff, manager, initialSelectedId, active }: {
handoff: (action: () => void) => void;
function PluginManagementContent({ manager }: {
manager: ReturnType<typeof usePluginManager>;
initialSelectedId: string | null;
active: boolean;
}) {
const { t } = useI18n();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(initialSelectedId);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
useEffect(() => {
setScopeIds((current) => resolvePluginSettingScopeSelection(scopeCatalog, current));
}, [scopeCatalog]);
const query = useMemo<NetcattyPluginContributionQuery>(() => ({
context: { 'netcatty.surface': 'settings' },
scopeIds,
}), [scopeIds]);
const contributions = usePluginContributions(query);
const settingsSection = useRef<HTMLElement | null>(null);
const installed = manager.plugins.find((plugin) => plugin.id === selectedInstalled);
const ownerKey = JSON.stringify(installed && [installed.id, installed.activeVersion, installed.archiveSha256,
installed.source?.id, installed.source?.kind, installed.source?.origin]);
const gearEntry = useRef(initialSelectedId ? { pluginId: initialSelectedId, ownerKey } : null);
useLayoutEffect(() => {
const intent = gearEntry.current;
if (!intent) return;
if (!active || pluginView !== 'installed' || selectedInstalled !== intent.pluginId || ownerKey !== intent.ownerKey) {
gearEntry.current = null;
return;
}
const section = settingsSection.current;
if (contributions.loading || contributions.error || !contributions.available || !section) return;
// Let dialog autofocus settle first, including when contributions arrive late.
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
if (gearEntry.current !== intent || !section.isConnected) return;
gearEntry.current = null;
section.scrollIntoView({ block: 'start' });
section.focus({ preventScroll: true });
});
});
return () => cancelAnimationFrame(frame);
}, [active, pluginView, selectedInstalled, ownerKey, contributions.loading, contributions.error, contributions.available, contributions.snapshot]);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application')),
[contributions.snapshot.plugins, selectedInstalled]);
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { gearEntry.current = null; setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
@@ -358,65 +41,7 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { gearEntry.current = null; setSelectedInstalled(id); }} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
<label key={kind} className="space-y-1 text-xs font-medium">
<span>{t('settings.plugins.scopeTarget').replace('{scope}', kind)}</span>
<select
value={scopeIds[kind] ?? ''}
onChange={(event) => setScopeIds((current) => ({ ...current, [kind]: event.target.value || undefined }))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-normal"
>
{!scopeCatalog[kind].length && <option value="">{t('settings.plugins.noScopeTargets')}</option>}
{scopeCatalog[kind].map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select>
</label>
))}
</section>
)}
{contributions.loading && <p className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{contributions.error && <p role="alert" className="text-sm text-destructive">{contributions.error.message}</p>}
{contributions.snapshot.plugins.filter((plugin) => plugin.id === selectedInstalled).map((plugin) => {
const settings = plugin.settings.filter((setting) => setting.visible);
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
return (
<section key={plugin.id} ref={settingsSection} tabIndex={-1} className="space-y-3 scroll-mt-4 focus:outline-none" aria-labelledby={`plugin-settings-${plugin.id}`}>
<div>
<h3 id={`plugin-settings-${plugin.id}`} className="text-base font-semibold">{plugin.displayName}</h3>
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
</div>
{settings.map((setting) => (
<PluginSettingField
key={setting.id}
pluginId={plugin.id}
setting={setting}
updateSetting={contributions.updateSetting}
resetSetting={contributions.resetSetting}
selectSettingPath={contributions.selectSettingPath}
availableFonts={availableFonts}
/>
))}
{views.map((view) => (
<div key={view.id} className="flex items-center justify-between rounded-lg border border-border/70 bg-background p-4">
<div className="flex min-w-0 items-center gap-3">
<PluginContributionIcon pluginId={plugin.id} icon={view.icon} size={18} className="shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium">{view.title}</div>
<div className="font-mono text-[10px] text-muted-foreground">{view.id}</div>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => handoff(() => requestOpenPluginView({
viewId: view.id,
context: { 'netcatty.surface': 'settings' },
}))}>{t('settings.plugins.openView')}</Button>
</div>
))}
</section>
);
})}
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { setSelectedInstalled(id); }} detailActions={selectedInstalled && <PluginSettingsEntry key={`settings:${selectedInstalled}`} pluginId={selectedInstalled} />} />
</TabsContent>
</Tabs>
</div>
@@ -425,27 +50,29 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
export default function SettingsPluginsTab() {
const { locale } = useI18n();
const manager = usePluginManager();
const updates = usePluginUpdateCheck(manager.plugins);
const [open, setOpen] = useState(false);
const [initialSelectedId, setInitialSelectedId] = useState<string | null>(null);
const [settingsPluginId, setSettingsPluginId] = useState<string | null>(null);
const opener = useRef<HTMLElement | null>(null);
const managerButton = useRef<HTMLButtonElement | null>(null);
const contributions = usePluginContributions({ context: { 'netcatty.surface': 'settings' } });
const contributions = usePluginContributions({ locale, context: { 'netcatty.surface': 'settings' } });
const settingsAvailable = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.settings.some((setting) => setting.visible) || plugin.views.some((view) => view.location === 'settings' && view.visible))
.filter((plugin) => plugin.settings.some((setting) => setting.visible))
.map((plugin) => plugin.id)), [contributions.snapshot]);
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="space-y-6">
<PluginSettingsLanding manager={manager} updates={updates} managerButtonRef={managerButton} settingsAvailable={settingsAvailable} settingsError={contributions.error?.message} onManage={(pluginId, element) => {
opener.current = element;
setInitialSelectedId(pluginId);
setOpen(true);
if (pluginId) setSettingsPluginId(pluginId);
else setOpen(true);
}} />
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={managerButton}>
{(handoff) => <PluginManagementContent handoff={handoff} manager={manager} initialSelectedId={initialSelectedId} active={open} />}
<PluginManagementContent manager={manager} />
</PluginManagementDialog>
{settingsPluginId && <PluginSettingsDialog key={settingsPluginId} pluginId={settingsPluginId} onClose={() => setSettingsPluginId(null)} restoreFocusTo={opener} />}
</SettingsAnchor>
</SettingsTabContent>
);
@@ -47,6 +47,8 @@ interface TempDirInfo {
path: string;
fileCount: number;
totalSize: number;
isCustom: boolean;
defaultPath: string;
}
interface SshDebugLogInfo {
@@ -196,6 +198,9 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
const { t } = useI18n();
const isMac = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
const [tempDirInput, setTempDirInput] = useState("");
const [tempDirError, setTempDirError] = useState("");
const [tempDirBusy, setTempDirBusy] = useState(false);
const [tempDirInfo, setTempDirInfo] = useState<TempDirInfo | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isClearing, setIsClearing] = useState(false);
@@ -235,6 +240,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
try {
const info = await bridge.getTempDirInfo();
setTempDirInfo(info);
if (info?.isCustom && info.path) setTempDirInput(info.path);
else if (info && !info.isCustom) setTempDirInput("");
} catch (err) {
console.error("[SettingsSystemTab] Failed to get temp dir info:", err);
} finally {
@@ -363,6 +370,37 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
}
}, [loadTempDirInfo]);
const applyTempDir = useCallback(async (dir: string) => {
const bridge = netcattyBridge.get();
if (!bridge?.setCustomTempDir) return;
setTempDirBusy(true);
setTempDirError("");
try {
const result = await bridge.setCustomTempDir(dir);
if (!result.ok) setTempDirError(result.error || t("settings.system.tempDirError"));
else { setTempDirInput(result.path || dir); await loadTempDirInfo(); }
} catch { setTempDirError(t("settings.system.tempDirError")); }
finally { setTempDirBusy(false); }
}, [loadTempDirInfo, t]);
const chooseTempDir = useCallback(async () => {
try {
const dir = await netcattyBridge.get()?.chooseTempDir?.();
if (dir) { setTempDirInput(dir); await applyTempDir(dir); }
} catch { setTempDirError(t("settings.system.tempDirError")); }
}, [applyTempDir, t]);
const resetTempDir = useCallback(async () => {
setTempDirBusy(true);
setTempDirError("");
try {
const result = await netcattyBridge.get()?.resetCustomTempDir?.();
if (!result?.ok) setTempDirError(result?.error || t("settings.system.tempDirError"));
else { setTempDirInput(""); await loadTempDirInfo(); }
} catch { setTempDirError(t("settings.system.tempDirError")); }
finally { setTempDirBusy(false); }
}, [loadTempDirInfo, t]);
const handleOpenTempDir = useCallback(async () => {
const bridge = netcattyBridge.get();
if (!tempDirInfo?.path || !bridge?.openTempDir) return;
@@ -925,6 +963,18 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
</Tooltip>
</div>
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={chooseTempDir} disabled={tempDirBusy}>{t("settings.system.tempDirChoose")}</Button>
{tempDirInfo?.isCustom && <Button variant="outline" size="sm" onClick={resetTempDir} disabled={tempDirBusy}>{t("settings.system.tempDirReset")}</Button>}
</div>
<div className="flex gap-2">
<input className="min-w-0 flex-1 rounded-md border border-input bg-background px-3 py-1 text-sm" aria-label={t("settings.system.tempDirManual")} placeholder={t("settings.system.tempDirManual")} value={tempDirInput} onChange={e => setTempDirInput(e.target.value)} />
<Button variant="outline" size="sm" onClick={() => applyTempDir(tempDirInput)} disabled={tempDirBusy}>{t("settings.system.tempDirApply")}</Button>
</div>
{tempDirError && <p role="alert" className="text-sm text-destructive">{t("settings.system.tempDirError")} {tempDirError}</p>}
</div>
{/* Stats */}
<div className="flex items-center gap-6 text-sm">
<div>
@@ -1,183 +1,154 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
import React, { act } from 'react';
import { JSDOM } from 'jsdom';
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
import { AIPrivacySection } from './AIPrivacySection';
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
const values = new Map<string, string>();
const events = new EventTarget();
const globals = {
IS_REACT_ACT_ENVIRONMENT: true,
localStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
window: {
netcatty: { externalMcpSetConfig: setConfig },
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events),
},
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries(globals)) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
}
return {
values,
restore: () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/#/settings' });
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'FocusEvent', 'getComputedStyle', 'localStorage'] as const) {
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true });
}
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (fn: () => void) => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout });
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
const { AIPrivacySection } = await import('./AIPrivacySection');
async function mount(setConfig: (config: unknown) => Promise<unknown> = async () => ({ ok: true }), saved?: string) {
dom.window.localStorage.clear();
if (saved) dom.window.localStorage.setItem(STORAGE_KEY_AI_PRIVACY, saved);
Object.assign(window, { netcatty: { externalMcpSetConfig: setConfig } });
const root = createRoot(document.getElementById('root')!);
await act(async () => root.render(<I18nProvider locale="en"><AIPrivacySection /></I18nProvider>));
return async () => { await act(async () => root.unmount()); };
}
const button = (text: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === text)!;
const click = async (text: string) => { await act(async () => button(text).click()); };
async function input(index: number, value: string) {
const el = document.querySelectorAll('textarea')[index];
await act(async () => {
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, 'value')!.set!.call(el, value);
el.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
});
}
function view() {
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
}
test('privacy settings default off and explain their limits and connection scope', async () => {
const env = installEnvironment(async () => ({ ok: true }));
let renderer: ReactTestRenderer | undefined;
test('privacy overview is persisted state; configuration preserves the full scope and atomic draft', async () => {
const unmount = await mount();
try {
await act(async () => { renderer = create(view()); });
const markup = JSON.stringify(renderer!.toJSON());
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
assert.match(markup, /cannot detect every secret/);
assert.match(markup, /AI privacy/);
assert.match(markup, /Catty, integrated agents and external MCP clients/);
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
assert.match(markup, /native tools, direct file access and other MCP servers/);
assert.match(markup, /Binary tool responses are blocked/);
assert.match(markup, /one managed conversation or external MCP connection/);
assert.match(markup, /reconnect external MCP clients/);
assert.match(markup, /start a new managed conversation/);
assert.match(markup, /Additional IP ranges/);
assert.match(markup, /Custom regular expressions/);
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
assert.equal(document.querySelector('textarea'), null);
assert.match(document.body.textContent!, /Text redaction disabled/);
await click('Configure privacy');
const dialog = document.querySelector('[role="dialog"]')!;
assert.match(dialog.textContent!, /cannot detect every secret/);
assert.match(dialog.textContent!, /Catty, integrated agents and external MCP clients/);
assert.match(dialog.textContent!, /Only Netcatty-managed data and tools are covered/);
assert.match(dialog.textContent!, /native tools, direct file access and other MCP servers/);
assert.match(dialog.textContent!, /Binary tool responses are blocked/);
assert.match(dialog.textContent!, /one managed conversation or external MCP connection/);
assert.match(dialog.textContent!, /reconnect external MCP clients/);
assert.equal(dialog.querySelectorAll('[role="switch"][aria-checked="false"]').length, 2);
assert.equal(button('Apply').disabled, true);
} finally { await unmount(); }
});
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
});
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
test('rejected privacy draft stays open and accepted draft commits once and returns focus', async () => {
const calls: unknown[] = [];
let accepted = false;
const env = installEnvironment(async (config) => {
calls.push(config);
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
});
let renderer: ReactTestRenderer | undefined;
const unmount = await mount(async (config) => { calls.push(config); return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' }; });
try {
await act(async () => { renderer = create(view()); });
const root = renderer!.root;
const privacySwitch = () => root.findAllByType('button').find((node) => (
node.props['aria-label'] === 'Enable text redaction'
))!;
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
await act(async () => { privacySwitch().props.onClick(); });
await act(async () => {
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
});
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
await act(async () => { apply().props.onClick(); });
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
const trigger = button('Configure privacy');
trigger.focus();
await click('Configure privacy');
await act(async () => (document.querySelector('[aria-label="Enable text redaction"]') as HTMLButtonElement).click());
await input(0, '192.0.2.0/24\n\n2001:db8::/32');
await input(1, '[');
assert.equal(calls.length, 0);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
await click('Apply');
assert.match(document.querySelector('[role="alert"]')!.textContent!, /Invalid privacy rules/);
assert.equal(document.querySelectorAll('textarea')[1].value, '[');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
accepted = true;
await act(async () => {
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
});
await act(async () => { apply().props.onClick(); });
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
assert.deepEqual(saved, {
enabled: true,
redactPrivateIps: false,
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
patterns: ['INTERNAL_[A-Z0-9]+'],
});
await input(1, 'INTERNAL_[A-Z0-9]+');
await click('Apply');
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY)!);
assert.deepEqual(saved, { enabled: true, redactPrivateIps: false, cidrs: ['192.0.2.0/24', '2001:db8::/32'], patterns: ['INTERNAL_[A-Z0-9]+'] });
assert.deepEqual(calls[1], { redaction: saved });
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.match(document.querySelector('[role="status"]')!.textContent!, /new managed conversation/);
await click('Configure privacy');
assert.equal(document.querySelectorAll('textarea')[1].value, 'INTERNAL_[A-Z0-9]+');
} finally { await unmount(); }
});
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
const env = installEnvironment(async () => ({ ok: true }));
let renderer: ReactTestRenderer | undefined;
test('Cancel, X and Escape discard edits and reopening reads saved values', async () => {
let calls = 0;
const unmount = await mount(async () => { calls += 1; return { ok: true }; });
try {
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
await act(async () => { renderer = create(view()); });
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
for (const close of ['Cancel', 'X', 'Escape']) {
await click('Configure privacy');
await input(0, '192.0.2.0/24');
assert.match(document.querySelector('[role="status"]')!.textContent!, /Unsaved changes/);
if (close === 'Cancel') await click('Cancel');
else if (close === 'X') await act(async () => (document.querySelector('[data-dialog-close]:not([aria-hidden])') as HTMLButtonElement).click());
else await act(async () => { document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
assert.equal(document.querySelector('[role="dialog"]'), null);
await click('Configure privacy');
assert.equal(document.querySelector('textarea')!.value, '');
await click('Cancel');
}
assert.equal(calls, 0);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
} finally { await unmount(); }
});
test('privacy apply disables draft controls until shared validation settles', async () => {
let resolveConfig!: (result: unknown) => void;
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
let renderer: ReactTestRenderer | undefined;
test('busy Apply blocks duplicate submits, edits and Escape', async () => {
let resolve!: (value: unknown) => void;
let calls = 0;
const unmount = await mount(() => { calls += 1; return new Promise((done) => { resolve = done; }); });
try {
await act(async () => { renderer = create(view()); });
const root = renderer!.root;
await act(async () => {
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
});
await act(async () => {
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
});
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
await act(async () => { resolveConfig({ ok: true }); });
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
await click('Configure privacy');
await input(0, '192.0.2.0/24');
await click('Apply');
assert.ok([...document.querySelectorAll('[role="dialog"] textarea, [role="dialog"] [role="switch"]')].every((el) => (el as HTMLInputElement).disabled));
assert.equal(button('Cancel').disabled, true);
await act(async () => { button('Applying...').click(); document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
assert.ok(document.querySelector('[role="dialog"]'));
assert.equal(calls, 1);
await act(async () => { resolve({ ok: true }); });
assert.equal(document.querySelector('[role="dialog"]'), null);
} finally { await unmount(); }
});
test('shared privacy controls refresh when another window changes the shared preference', async () => {
const env = installEnvironment(async () => ({ ok: true }));
let renderer: ReactTestRenderer | undefined;
test('malformed saved settings remain visible and repairable without being rewritten on open', async () => {
const unmount = await mount(undefined, '{');
try {
await act(async () => { renderer = create(view()); });
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
const event = new Event('storage');
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
await act(async () => { window.dispatchEvent(event); });
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
assert.match(document.querySelector('[role="alert"]')!.textContent!, /Invalid saved AI privacy settings/);
await click('Configure privacy');
assert.equal(button('Apply').disabled, false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), '{');
} finally { await unmount(); }
});
test('cross-window changes refresh clean drafts but preserve dirty drafts with a conflict notice', async () => {
const unmount = await mount();
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
const update = async () => {
dom.window.localStorage.setItem(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
await act(async () => { window.dispatchEvent(new dom.window.StorageEvent('storage', { key: STORAGE_KEY_AI_PRIVACY })); });
};
try {
await click('Configure privacy');
await update();
assert.equal(document.querySelector('textarea')!.value, '192.0.2.0/24');
await input(0, '198.51.100.0/24');
settings.cidrs = ['203.0.113.0/24'];
await update();
assert.equal(document.querySelector('textarea')!.value, '198.51.100.0/24');
assert.match(document.querySelector('[role="alert"]')!.textContent!, /another window/);
assert.equal(button('Apply').disabled, true);
await click('Cancel');
await click('Configure privacy');
assert.equal(document.querySelector('textarea')!.value, '203.0.113.0/24');
} finally { await unmount(); }
});
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
import { Button } from "../../../ui/button";
import { ToolSettingsDialog } from "./ToolSettingsDialog";
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
export const AIPrivacySection: React.FC = () => {
@@ -12,8 +13,32 @@ export const AIPrivacySection: React.FC = () => {
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
const [applied, setApplied] = useState(false);
const [open, setOpen] = useState(false);
const errorRef = useRef<HTMLParagraphElement>(null);
useEffect(() => { if (open && error) errorRef.current?.focus(); }, [open, error]);
const [draftBase, setDraftBase] = useState(settings);
const [conflict, setConflict] = useState(false);
const dirty = enabled !== draftBase.enabled || privateIps !== draftBase.redactPrivateIps
|| cidrs !== draftBase.cidrs.join("\n") || patterns !== draftBase.patterns.join("\n");
const changeOpen = (next: boolean) => {
if (applying) return;
setDraftBase(settings);
setEnabled(settings.enabled);
setPrivateIps(settings.redactPrivateIps);
setCidrs(settings.cidrs.join("\n"));
setPatterns(settings.patterns.join("\n"));
setConflict(false);
setOpen(next);
};
const draftState = useRef({ open, dirty, applying });
draftState.current = { open, dirty, applying };
useEffect(() => {
if (draftState.current.open && draftState.current.dirty && !draftState.current.applying) {
setConflict(true);
return;
}
setDraftBase(settings);
setEnabled(settings.enabled);
setPrivateIps(settings.redactPrivateIps);
setCidrs(settings.cidrs.join("\n"));
@@ -21,6 +46,7 @@ export const AIPrivacySection: React.FC = () => {
}, [settings]);
const onApply = async () => {
if (applying || conflict) return;
setApplied(false);
const saved = await apply({
enabled,
@@ -29,13 +55,36 @@ export const AIPrivacySection: React.FC = () => {
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
});
setApplied(saved);
if (saved) setOpen(false);
};
return (
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingCard>
<SettingRow
className="flex-col items-start md:flex-row md:items-center"
label={t(settings.enabled ? "ai.toolSettings.privacyOn" : "ai.toolSettings.privacyOff")}
description={`${t("ai.privacy.privateIps")}: ${t(settings.redactPrivateIps ? "common.enabled" : "common.disabled")} · ${t("ai.toolSettings.ruleSummary", { cidrs: String(settings.cidrs.length), patterns: String(settings.patterns.length) })}`}
>
<ToolSettingsDialog
title={t("ai.privacy.title")}
triggerLabel={t("ai.toolSettings.configurePrivacy")}
open={open}
onOpenChange={changeOpen}
busy={applying}
footer={<>
{dirty ? <span role="status" className="mr-auto self-center text-xs text-muted-foreground">{t("ai.toolSettings.unsaved")}</span> : null}
<Button type="button" variant="outline" onClick={() => changeOpen(false)} disabled={applying}>{t("common.cancel")}</Button>
<Button type="button" onClick={() => { void onApply(); }} disabled={applying || conflict || (!dirty && !error)}>
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
</Button>
</>}
>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
<SettingCard divided className="border-0 rounded-none bg-transparent p-0">
<SettingRow label={t("ai.privacy.enable")}>
<Toggle
checked={enabled}
@@ -56,9 +105,9 @@ export const AIPrivacySection: React.FC = () => {
/>
</SettingRow>
</SettingCard>
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
<div className="space-y-4">
<label className="block space-y-1.5">
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
<span className="text-sm font-medium">{t("ai.privacy.cidrs")}</span>
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
<textarea
value={cidrs}
@@ -72,7 +121,7 @@ export const AIPrivacySection: React.FC = () => {
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
<span className="text-sm font-medium">{t("ai.privacy.patterns")}</span>
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
<textarea
value={patterns}
@@ -86,20 +135,15 @@ export const AIPrivacySection: React.FC = () => {
/>
</label>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
</Button>
</div>
{error ? (
<p role="alert" className="text-xs text-destructive">
{t("ai.privacy.failed")} {error}
</p>
) : applied ? (
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
) : null}
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
</div>
{conflict ? <p role="alert" className="text-xs text-destructive">{t("ai.toolSettings.conflict")}</p> : null}
{error ? <p ref={errorRef} tabIndex={-1} role="alert" className="text-xs text-destructive">{t("ai.privacy.failed")} {error}</p> : null}
</ToolSettingsDialog>
</SettingRow>
</SettingCard>
{error && !open ? <p role="alert" className="text-xs text-destructive">{t("ai.privacy.failed")} {error}</p>
: applied ? <p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p> : null}
</SettingsSection>
);
};
@@ -1,10 +1,12 @@
import React from "react";
import { Check, Copy } from "lucide-react";
import { Check, Copy, Eye, EyeOff } from "lucide-react";
import { cn } from "../../../../lib/utils";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
type CopyableCodeBlockProps = {
label?: string;
sensitive?: boolean;
value: string;
copyKey: string;
copied: string | null;
@@ -17,6 +19,7 @@ type CopyableCodeBlockProps = {
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
sensitive = false,
value,
copyKey,
copied,
@@ -26,7 +29,9 @@ export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const { t } = useI18n();
const [revealed, setRevealed] = React.useState(false);
const display = sensitive && value && !revealed ? "••••••••" : value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
@@ -38,12 +43,15 @@ export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-20 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
{sensitive && value ? <Button type="button" variant="ghost" size="sm" className="absolute right-10 top-1.5 h-7 w-7 p-0" onClick={() => setRevealed(!revealed)} aria-label={t(revealed ? "ai.toolSettings.hideSecret" : "ai.toolSettings.showSecret")}>
{revealed ? <EyeOff size={14} /> : <Eye size={14} />}
</Button> : null}
<Button
type="button"
variant="ghost"
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { JSDOM } from 'jsdom';
import { installAiToolAccessFixture } from '../../../../scripts/fixtures/ai-tool-access/bridge.cjs';
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/#/settings' });
for (const key of ['window', 'document', 'location', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'FocusEvent', 'getComputedStyle', 'localStorage'] as const) {
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true });
}
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (fn: () => void) => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout });
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
const { TooltipProvider } = await import('../../../ui/tooltip');
const { ExternalMcpCard } = await import('./ExternalMcpCard');
type FixtureControl = { rejectHttp: boolean; throwHttp: boolean; holdHttp: boolean; pendingHttp: (() => void)[]; pendingClients: (() => void)[]; rejectRotate: boolean; throwRotate: boolean; calls: { name: string; args: unknown[] }[] };
async function mount(options: { enabled?: boolean; holdClients?: boolean } = {}, openHttp = true) {
installAiToolAccessFixture({ locale: 'en', enabled: true, ...options });
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
const qa = (window as unknown as { __aiToolQa: FixtureControl }).__aiToolQa;
const root = createRoot(document.getElementById('root')!);
await act(async () => root.render(<I18nProvider locale="en"><TooltipProvider><ExternalMcpCard /></TooltipProvider></I18nProvider>));
if (openHttp) await click('Configure HTTP');
return { qa, close: async () => { await act(async () => root.unmount()); } };
}
const dialog = () => document.querySelector<HTMLElement>('[role="dialog"]')!;
const button = (text: string, scope: ParentNode = document) => [...scope.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === text)!;
const click = async (text: string) => { await act(async () => button(text).click()); };
async function changePort(value: string) {
const port = dialog().querySelector<HTMLInputElement>('[aria-label="Port"]')!;
await act(async () => {
port.focus();
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!.call(port, value);
port.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
});
await act(async () => port.blur());
return port;
}
for (const failure of ['returned', 'thrown'] as const) {
test(`HTTP ${failure} configuration failure is visible inside the dialog, preserves input and can retry`, async () => {
const h = await mount();
try {
h.qa.rejectHttp = failure === 'returned';
h.qa.throwHttp = failure === 'thrown';
const port = await changePort('47401');
const message = failure === 'returned' ? 'Fixture: port unavailable.' : 'Fixture: HTTP configuration threw.';
const alert = dialog().querySelector('[role="alert"]')!;
assert.match(alert.textContent!, new RegExp(message.replaceAll('.', '\\.')));
assert.equal(document.activeElement, alert, 'the error must be reachable without scrolling back manually');
assert.equal(port.value, '47401');
assert.ok(document.querySelector('[role="dialog"]'));
assert.match(document.querySelector('[data-settings-anchor="ai-external-mcp-http"]')!.textContent!, /Fixture:/);
const beforeRetry = h.qa.calls.filter((call) => call.name === 'config').length;
h.qa.rejectHttp = false;
h.qa.throwHttp = false;
await click('Retry');
assert.equal(h.qa.calls.filter((call) => call.name === 'config').length, beforeRetry + 1);
assert.equal(dialog().querySelector('[role="alert"]'), null);
assert.equal(port.value, '47401');
} finally { await h.close(); }
});
}
for (const failure of ['returned', 'thrown'] as const) {
test(`HTTP ${failure} token rotation failure stays in the active dialog and retries explicitly`, async () => {
const h = await mount();
try {
await act(async () => (dialog().querySelector('[aria-label="Enable HTTP"]') as HTMLButtonElement).click());
await act(async () => dialog().querySelector('summary')!.click());
h.qa.rejectRotate = failure === 'returned';
h.qa.throwRotate = failure === 'thrown';
await click('Regenerate');
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /Fixture: token rotation/);
assert.equal(dialog().textContent!.includes('SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL'), false);
h.qa.rejectRotate = false;
h.qa.throwRotate = false;
await click('Retry');
assert.equal(dialog().querySelector('[role="alert"]'), null);
assert.equal(dialog().textContent!.includes('SYNTHETIC_ROTATED_FIXTURE_TOKEN'), false);
} finally { await h.close(); }
});
}
test('HTTP clipboard failure is visible in the dialog and clears only after another copy attempt', async () => {
const h = await mount();
try {
await act(async () => dialog().querySelector('summary')!.click());
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new Error('Fixture clipboard failure'); } } });
const copy = () => dialog().querySelector<HTMLButtonElement>('[aria-label="Copy"]:not([disabled])')!;
await act(async () => copy().click());
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /copy/i);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
await act(async () => copy().click());
assert.equal(dialog().querySelector('[role="alert"]'), null);
} finally { await h.close(); }
});
for (const failure of ['returned', 'thrown'] as const) {
test(`a completed runtime poll cannot suppress a pending HTTP ${failure} failure`, async () => {
const h = await mount();
try {
h.qa.holdHttp = true;
h.qa.rejectHttp = failure === 'returned';
h.qa.throwHttp = failure === 'thrown';
const beforePoll = h.qa.calls.filter((call) => call.name === 'status').length;
const port = await changePort('47402');
assert.match(dialog().querySelector('[role="status"]')!.textContent!, /Applying HTTP/);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3200)); });
assert.ok(h.qa.calls.filter((call) => call.name === 'status').length > beforePoll);
await act(async () => h.qa.pendingHttp.splice(0).forEach((resolve) => resolve()));
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /Fixture:/);
assert.equal(port.value, '47402');
assert.equal(dialog().querySelector('[role="status"]'), null);
} finally { await h.close(); }
});
}
test('completed MCP enable can be turned off while unrelated client discovery is still pending', async () => {
const h = await mount({ enabled: false, holdClients: true }, false);
try {
const toggle = document.querySelector<HTMLButtonElement>('[role="switch"][aria-label="External MCP"]')!;
await act(async () => toggle.click());
assert.equal(toggle.getAttribute('aria-checked'), 'true');
assert.ok(h.qa.pendingClients.length > 0, 'client discovery remains intentionally unresolved');
assert.equal(toggle.disabled, false, 'successful enable must release the switch before client discovery finishes');
await act(async () => toggle.click());
assert.equal(toggle.getAttribute('aria-checked'), 'false');
assert.equal(toggle.disabled, false);
assert.deepEqual(h.qa.calls.filter((call) => call.name === 'enable').map((call) => call.args[0]), [true, false]);
} finally { await h.close(); }
});
+195 -177
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
@@ -31,8 +31,13 @@ import {
} from "../../../../application/state/useExternalMcpToggleState";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../../settings/settings-ui";
import { ToolStatusPill, type ToolStatusTone } from "./ToolStatusPill";
import { ToolSettingsDialog } from "./ToolSettingsDialog";
import { useOptionalSettingsFocus } from "../../SettingsFocusContext";
import { SettingsAnchor } from "../../settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
import { getBridge } from "./types";
@@ -72,23 +77,13 @@ type StatusView = {
className: string;
};
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): StatusView {
if (!enabled) {
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
}
if (!status || !status.ok) {
return { labelKey: "ai.externalMcp.status.unavailable", className: "text-amber-500" };
}
if (status.state === "running") {
return { labelKey: "ai.externalMcp.status.running", className: "text-emerald-500" };
}
if (status.state === "starting") {
return { labelKey: "ai.externalMcp.status.starting", className: "text-amber-500" };
}
if (status.state === "error") {
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
}
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): { labelKey: string; tone: ToolStatusTone } {
if (!enabled) return { labelKey: "ai.externalMcp.status.disabled", tone: "inactive" };
if (!status || !status.ok) return { labelKey: "ai.externalMcp.status.unavailable", tone: status?.error ? "error" : "inactive" };
if (status.state === "running") return { labelKey: "ai.externalMcp.status.running", tone: "active" };
if (status.state === "starting") return { labelKey: "ai.externalMcp.status.starting", tone: "inactive" };
if (status.state === "error") return { labelKey: "ai.externalMcp.status.error", tone: "error" };
return { labelKey: "ai.externalMcp.status.disabled", tone: "inactive" };
}
/** Map bridge permissionMode to a Safety i18n key for display. */
@@ -252,6 +247,15 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
export const ExternalMcpCard: React.FC = () => {
const { t } = useI18n();
const [behaviorOpen, setBehaviorOpen] = useState(false);
const focus = useOptionalSettingsFocus();
useEffect(() => {
if (focus?.request?.anchorId === "ai-external-mcp-approval-alert") setBehaviorOpen(true);
}, [focus?.request]);
const statusGeneration = useRef(0);
const clientStatusGeneration = useRef(0);
const httpOperationGeneration = useRef(0);
useEffect(() => () => { statusGeneration.current += 1; clientStatusGeneration.current += 1; httpOperationGeneration.current += 1; }, []);
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
@@ -268,8 +272,11 @@ export const ExternalMcpCard: React.FC = () => {
listenAddress: readExternalMcpHttpListenAddress(),
clientHost: readExternalMcpHttpClientHost(),
}));
const [isApplyingHttp, setIsApplyingHttp] = useState(false);
const [isToggling, setIsToggling] = useState(false);
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
const [manualFormat, setManualFormat] = useState("command");
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
const [claudeStatus, setClaudeStatus] = useState<ClientSetupStatus | null>(null);
@@ -280,6 +287,8 @@ export const ExternalMcpCard: React.FC = () => {
const [isAddingClaude, setIsAddingClaude] = useState(false);
const [isAddingGrok, setIsAddingGrok] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const [httpCopyError, setHttpCopyError] = useState<string | null>(null);
const [httpActionError, setHttpActionError] = useState<{ kind: "config" | "rotate"; message: string } | null>(null);
const [actionMessage, setActionMessage] = useState<{ tone: "error" | "warning" | "success"; text: string } | null>(null);
const bridgeUnavailableMessage = t("ai.externalMcp.bridgeUnavailable");
@@ -324,6 +333,10 @@ export const ExternalMcpCard: React.FC = () => {
}, []);
const applyHttpSettings = useCallback(async (next: ExternalMcpHttpSettings) => {
const operation = ++httpOperationGeneration.current;
const generation = ++statusGeneration.current;
setHttpActionError(null);
setIsApplyingHttp(true);
const normalized: ExternalMcpHttpSettings = {
enabled: writeExternalMcpHttpEnabled(next.enabled),
port: writeExternalMcpHttpPort(next.port),
@@ -332,36 +345,56 @@ export const ExternalMcpCard: React.FC = () => {
clientHost: writeExternalMcpHttpClientHost(next.clientHost),
};
setHttpSettings(normalized);
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
httpEnabled: normalized.enabled,
httpPort: normalized.port,
httpAllowCustomBind: normalized.allowCustomBind,
httpListenAddress: normalized.listenAddress,
httpClientHost: normalized.clientHost,
});
if (nextStatus && typeof nextStatus === "object") {
setStatus(nextStatus as ExternalMcpStatus);
try {
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
httpEnabled: normalized.enabled,
httpPort: normalized.port,
httpAllowCustomBind: normalized.allowCustomBind,
httpListenAddress: normalized.listenAddress,
httpClientHost: normalized.clientHost,
}) as ExternalMcpStatus | undefined;
if (operation !== httpOperationGeneration.current) return;
if (!nextStatus?.ok || nextStatus.error || nextStatus.http?.error) {
setHttpActionError({ kind: "config", message: nextStatus?.error || nextStatus?.http?.error || bridgeUnavailableMessage });
return;
}
if (generation === statusGeneration.current) setStatus(nextStatus);
} catch (error) {
if (operation === httpOperationGeneration.current) {
setHttpActionError({ kind: "config", message: error instanceof Error ? error.message : String(error) });
}
} finally {
if (operation === httpOperationGeneration.current) setIsApplyingHttp(false);
}
}, []);
}, [bridgeUnavailableMessage]);
const rotateHttpToken = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpSetConfig) return;
const operation = ++httpOperationGeneration.current;
const generation = ++statusGeneration.current;
setIsRotatingHttpToken(true);
setActionMessage(null);
setHttpActionError(null);
try {
const nextStatus = await bridge.externalMcpSetConfig({ rotateHttpToken: true });
if (nextStatus && typeof nextStatus === "object") {
setStatus(nextStatus as ExternalMcpStatus);
const nextStatus = await getBridge()?.externalMcpSetConfig?.({ rotateHttpToken: true }) as ExternalMcpStatus | undefined;
if (operation !== httpOperationGeneration.current) return;
if (!nextStatus?.ok || nextStatus.error || nextStatus.http?.error) {
setHttpActionError({ kind: "rotate", message: nextStatus?.error || nextStatus?.http?.error || bridgeUnavailableMessage });
return;
}
if (generation === statusGeneration.current) setStatus(nextStatus);
} catch (error) {
if (operation === httpOperationGeneration.current) {
setHttpActionError({ kind: "rotate", message: error instanceof Error ? error.message : String(error) });
}
} finally {
setIsRotatingHttpToken(false);
}
}, []);
}, [bridgeUnavailableMessage]);
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
const generation = ++statusGeneration.current;
const bridge = getBridge();
const includeClients = options?.clients !== false;
const clientGeneration = includeClients ? ++clientStatusGeneration.current : clientStatusGeneration.current;
if (
!bridge?.externalMcpGetStatus
|| !bridge?.externalMcpCodexGetStatus
@@ -411,10 +444,15 @@ export const ExternalMcpCard: React.FC = () => {
bridge.externalMcpGetUniversalSetupPrompt?.()
?? Promise.resolve({ ok: false, prompt: "" }),
]);
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
// Runtime polling and CLI/client discovery have separate lifetimes.
// A fast quiet poll must not discard a slow, still-current client batch.
if (generation === statusGeneration.current) {
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
}
}
if (clientGeneration !== clientStatusGeneration.current) return;
setCodexStatus(nextCodexStatus as ClientSetupStatus);
setClaudeStatus(nextClaudeStatus as ClientSetupStatus);
setGrokStatus(nextGrokStatus as ClientSetupStatus);
@@ -425,13 +463,14 @@ export const ExternalMcpCard: React.FC = () => {
);
} else {
const nextStatus = await bridge.externalMcpGetStatus();
if (generation !== statusGeneration.current) return;
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
}
}
} finally {
if (!options?.quiet) setIsRefreshing(false);
if (!options?.quiet && clientGeneration === clientStatusGeneration.current) setIsRefreshing(false);
}
}, [bridgeUnavailableMessage, enabled, setEnabled]);
@@ -481,8 +520,10 @@ export const ExternalMcpCard: React.FC = () => {
const canAddToClaude = claudeStatus?.state === "not_configured";
const canAddToGrok = grokStatus?.state === "not_configured";
const copyText = useCallback(async (key: string, text: string) => {
const copyText = useCallback(async (key: string, text: string, scope: "client" | "http" = "client") => {
if (!text) return;
setCopied((current) => current === key ? null : current);
if (scope === "http") setHttpCopyError(null);
try {
await navigator.clipboard.writeText(text);
setCopied(key);
@@ -490,7 +531,8 @@ export const ExternalMcpCard: React.FC = () => {
setCopied((current) => (current === key ? null : current));
}, 1200);
} catch {
setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
if (scope === "http") setHttpCopyError(t("ai.externalMcp.copyFailed"));
else setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
}
}, [t]);
@@ -634,8 +676,13 @@ export const ExternalMcpCard: React.FC = () => {
]);
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<SettingsSection
anchorId="ai-external-mcp"
title={t("ai.externalMcp.title")}
actions={<ToolStatusPill tone={bridgeStatusView.tone}>{t(bridgeStatusView.labelKey)}</ToolStatusPill>}
>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="min-w-0 flex items-start gap-1.5">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.description")}
@@ -665,12 +712,10 @@ export const ExternalMcpCard: React.FC = () => {
</TooltipContent>
</Tooltip>
</div>
<div className={cn("text-xs font-medium shrink-0", bridgeStatusView.className)}>
{t(bridgeStatusView.labelKey)}
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
<SettingCard divided>
<div className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<div className="text-sm font-medium">{t("ai.externalMcp.title")}</div>
<div className="text-xs text-muted-foreground">
@@ -679,17 +724,21 @@ export const ExternalMcpCard: React.FC = () => {
</div>
<Toggle
checked={enabled}
disabled={isToggling}
ariaLabel={t("ai.externalMcp.title")}
onChange={(nextEnabled) => {
statusGeneration.current += 1;
setActionMessage(null);
void setEnabled(nextEnabled).then(() => refreshStatus());
setIsToggling(true);
void setEnabled(nextEnabled).then(() => { void refreshStatus(); }).finally(() => setIsToggling(false));
}}
/>
</div>
{/* Permission mode is controlled in Safety settings; surface it here so External MCP
users see why write tools may still prompt (confirm) or run freely (auto). */}
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
<div className="min-w-0 space-y-1">
<div className="flex items-start justify-between gap-4 py-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="text-sm font-medium">{t("ai.externalMcp.permissionMode.label")}</div>
<div className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.permissionMode.hint")}
@@ -706,9 +755,11 @@ export const ExternalMcpCard: React.FC = () => {
</div>
</div>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingsAnchor anchorId="ai-external-mcp-approval-alert">
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.toolSettings.behavior")} description={t(mode === "persistent" ? "ai.externalMcp.mode.persistent" : "ai.externalMcp.mode.temporary")}>
<ToolSettingsDialog title={t("ai.toolSettings.behavior")} triggerLabel={t("ai.toolSettings.configureBehavior")} immediate open={behaviorOpen} onOpenChange={setBehaviorOpen}>
<SettingCard divided className="border-0 bg-transparent p-0">
<SettingRow
anchorId="ai-external-mcp-approval-alert"
label={t("ai.externalMcp.approvalAlert")}
description={t("ai.externalMcp.approvalAlert.description")}
>
@@ -803,6 +854,10 @@ export const ExternalMcpCard: React.FC = () => {
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
</div>
</SettingRow>
</SettingCard>
</ToolSettingsDialog>
</SettingRow>
</SettingsAnchor>
</SettingCard>
<ExternalMcpHttpSection
@@ -811,93 +866,36 @@ export const ExternalMcpCard: React.FC = () => {
status={status?.http || null}
copied={copied}
rotating={isRotatingHttpToken}
onCopy={copyText}
onCopy={(key, text) => { void copyText(key, text, "http"); }}
applying={isApplyingHttp}
operationError={httpActionError?.message ?? null}
copyError={httpCopyError}
onRetry={httpActionError ? () => {
if (httpActionError.kind === "rotate") void rotateHttpToken();
else void applyHttpSettings(httpSettings);
} : undefined}
onChange={(next) => { void applyHttpSettings(next); }}
onRotateToken={() => { void rotateHttpToken(); }}
/>
<div className="space-y-2">
<div className="flex min-h-8 items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
<Button
variant="outline"
size="sm"
onClick={() => void refreshStatus()}
disabled={isRefreshing}
>
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
{t("ai.externalMcp.refresh")}
</Button>
</div>
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
<CopyableCodeBlock
label={t("ai.externalMcp.launcher")}
value={launcherPath || ""}
copyKey="launcher"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.discovery")}
value={status?.discoveryPath || ""}
copyKey="discovery"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
{!enabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
) : null}
</div>
</div>
<SettingCard>
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.externalMcp.clientConfiguration")} description={t("ai.externalMcp.clientConfiguration.description")}>
<ToolSettingsDialog title={t("ai.externalMcp.clientConfiguration")} triggerLabel={t("ai.toolSettings.configureClients")}>
<div className="space-y-2">
<div className="space-y-1">
<div className="text-sm font-semibold text-foreground">
{t("ai.externalMcp.clientConfiguration")}
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.clientConfiguration.description")}
</p>
</div>
<div
role="tablist"
aria-label={t("ai.externalMcp.clientConfiguration")}
className="grid grid-cols-5 gap-1 rounded-md bg-muted p-1"
>
{CLIENT_TABS.map((client) => {
const active = selectedClient === client;
return (
<button
key={client}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setSelectedClient(client);
setActionMessage(null);
}}
className={cn(
"inline-flex h-8 items-center justify-center rounded-sm px-2 text-xs font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`ai.externalMcp.client.${client}`)}
</button>
);
})}
</div>
<Tabs value={selectedClient} onValueChange={(value) => { setSelectedClient(value as ExternalMcpClient); setManualFormat(value === "other" || value === "cursor" ? "snippet" : "command"); setActionMessage(null); }}>
<TabsList aria-label={t("ai.externalMcp.clientConfiguration")} className="flex h-auto flex-wrap justify-start">
{CLIENT_TABS.map((client) => <TabsTrigger key={client} value={client}>{t(`ai.externalMcp.client.${client}`)}</TabsTrigger>)}
</TabsList>
<TabsContent value={selectedClient}>
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
<div className="space-y-4">
{selectedClientMeta.kind === "installable" && selectedClientMeta.statusView ? (
<div className="flex items-center justify-between gap-3">
<div className={cn("text-xs font-medium", selectedClientMeta.statusView.className)}>
@@ -924,57 +922,76 @@ export const ExternalMcpCard: React.FC = () => {
</p>
)}
{selectedClientMeta.kind === "installable" ? (
<>
<CopyableCodeBlock
label={t("ai.externalMcp.cliCommand")}
value={selectedClientMeta.command}
copyKey="command"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.configSnippet")}
value={selectedClientMeta.snippet}
copyKey="snippet"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</>
) : (
<>
<CopyableCodeBlock
label={t("ai.externalMcp.configSnippet")}
value={selectedClientMeta.snippet}
copyKey="cursor"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
{selectedClient === "other" ? (
<CopyableCodeBlock
label={t("ai.externalMcp.universalSetupPrompt")}
value={universalSetupPrompt}
copyKey="universal-setup-prompt"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
) : null}
</>
)}
<details className="space-y-3">
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.manualSetup")}</summary>
<label className="flex flex-wrap items-center justify-between gap-2 text-sm">
{t("ai.toolSettings.format")}
<select aria-label={t("ai.toolSettings.format")} className="h-9 max-w-full rounded-md border border-input bg-background px-3 text-sm" value={manualFormat} onChange={(event) => setManualFormat(event.currentTarget.value)}>
{selectedClientMeta.kind === "installable" ? <option value="command">{t("ai.externalMcp.cliCommand")}</option> : null}
<option value="snippet">{t("ai.externalMcp.configSnippet")}</option>
{selectedClient === "other" ? <option value="prompt">{t("ai.externalMcp.universalSetupPrompt")}</option> : null}
</select>
</label>
<CopyableCodeBlock
label={t(manualFormat === "command" ? "ai.externalMcp.cliCommand" : manualFormat === "prompt" ? "ai.externalMcp.universalSetupPrompt" : "ai.externalMcp.configSnippet")}
value={manualFormat === "command" ? selectedClientMeta.command : manualFormat === "prompt" ? universalSetupPrompt : selectedClientMeta.snippet}
copyKey={manualFormat}
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</details>
</div>
</TabsContent>
</Tabs>
</div>
<details className="space-y-3">
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.connectionDetails")}</summary>
<div className="flex min-h-8 items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
<Button
variant="outline"
size="sm"
onClick={() => void refreshStatus()}
disabled={isRefreshing}
>
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
{t("ai.externalMcp.refresh")}
</Button>
</div>
<div className="space-y-3">
<CopyableCodeBlock
label={t("ai.externalMcp.launcher")}
value={launcherPath || ""}
copyKey="launcher"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.discovery")}
value={status?.discoveryPath || ""}
copyKey="discovery"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
{!enabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
) : null}
</div>
</details>
{actionMessage ? <p role={actionMessage.tone === "error" ? "alert" : "status"} className={cn("text-xs", actionMessage.tone === "error" ? "text-destructive" : "text-muted-foreground")}>{actionMessage.text}</p> : null}
</ToolSettingsDialog>
</SettingRow>
</SettingCard>
{actionMessage ? (
<div
@@ -991,6 +1008,7 @@ export const ExternalMcpCard: React.FC = () => {
{configError || status?.error ? (
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
) : null}
</div>
</div>
</SettingsSection>
);
};
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useId, useRef, useState } from "react";
import {
buildClaudeHttpAddCommand,
buildCodexHttpEnvLine,
@@ -8,9 +8,12 @@ import {
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
} from "../../../../domain/externalMcpHttp";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
import { ToolStatusPill } from "./ToolStatusPill";
import { ToolSettingsDialog } from "./ToolSettingsDialog";
import { useOptionalSettingsFocus } from "../../SettingsFocusContext";
import { SettingsAnchor } from "../../settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
export type ExternalMcpHttpViewStatus = {
@@ -41,6 +44,10 @@ type ExternalMcpHttpSectionProps = {
status: ExternalMcpHttpViewStatus | null;
copied: string | null;
rotating: boolean;
applying?: boolean;
operationError?: string | null;
copyError?: string | null;
onRetry?: () => void;
onCopy: (key: string, text: string) => void;
onChange: (next: ExternalMcpHttpViewSettings) => void;
onRotateToken: () => void;
@@ -73,11 +80,25 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
status,
copied,
rotating,
applying,
operationError,
copyError,
onRetry,
onCopy,
onChange,
onRotateToken,
}) => {
const { t } = useI18n();
const operationErrorRef = useRef<HTMLDivElement>(null);
const errorId = useId();
const [open, setOpen] = useState(false);
useEffect(() => {
if (open && (operationError || copyError)) operationErrorRef.current?.focus();
}, [open, operationError, copyError]);
const focus = useOptionalSettingsFocus();
useEffect(() => {
if (focus?.request?.anchorId === "ai-external-mcp-http") setOpen(true);
}, [focus?.request]);
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
@@ -85,6 +106,16 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
const [addressError, setAddressError] = useState(false);
const [clientHostError, setClientHostError] = useState(false);
const changeOpen = (next: boolean) => {
setPortDraft(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
setAddressDraft(settings.listenAddress || "0.0.0.0");
setClientHostDraft(settings.clientHost || "");
setPortError(false);
setAddressError(false);
setClientHostError(false);
setOpen(next);
};
const listenAddress = settings.allowCustomBind
? (status?.listenAddress || settings.listenAddress)
: "127.0.0.1";
@@ -97,14 +128,9 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
);
const token = status?.bearerToken || "";
const listening = Boolean(status?.listening);
const statusLabel = listening
? t("ai.externalMcp.http.listening")
: t("ai.externalMcp.http.notListening");
const statusClass = status?.errorCode
? "text-destructive"
: listening
? "text-emerald-500"
: "text-muted-foreground";
const hasRuntimeError = Boolean(operationError || status?.errorCode || status?.error);
const statusLabel = hasRuntimeError ? t("ai.externalMcp.status.error")
: t(listening ? "ai.externalMcp.http.listening" : "ai.externalMcp.http.notListening");
let errorText = "";
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
@@ -116,6 +142,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
else if (status?.error) errorText = status.error;
const commitPort = () => {
const port = parsePort(portDraft);
@@ -151,16 +178,25 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
};
return (
<div className="space-y-2">
<SettingsAnchor anchorId="ai-external-mcp-http" className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
<ToolStatusPill tone={hasRuntimeError ? "error" : listening ? "active" : "inactive"}>{statusLabel}</ToolStatusPill>
</div>
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingCard>
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t(settings.enabled ? "ai.toolSettings.httpOn" : "ai.toolSettings.httpOff")} description={settings.allowCustomBind ? t("ai.externalMcp.http.warning") : t("ai.toolSettings.loopback")}>
<ToolSettingsDialog title={t("ai.externalMcp.http.title")} triggerLabel={t("ai.toolSettings.configureHttp")} immediate open={open} onOpenChange={changeOpen}>
{applying ? <p role="status" className="text-xs text-muted-foreground">{t("ai.toolSettings.applyingHttp")}</p> : null}
{operationError || copyError ? <div ref={operationErrorRef} role="alert" tabIndex={-1} className="space-y-2 text-xs text-destructive">
{operationError ? <p>{operationError}</p> : null}
{copyError ? <p>{copyError}</p> : null}
{operationError && onRetry ? <Button type="button" variant="outline" size="sm" disabled={rotating || applying} onClick={onRetry}>{t("ai.toolSettings.retry")}</Button> : null}
</div> : null}
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
<SettingCard divided className="border-0 bg-transparent p-0">
<SettingRow
anchorId="ai-external-mcp-http"
label={t("ai.externalMcp.http.enable")}
>
<Toggle
@@ -176,6 +212,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<input
type="number"
aria-label={t("ai.externalMcp.http.port")}
aria-invalid={portError}
aria-describedby={portError ? errorId : undefined}
min={1024}
max={65535}
value={portDraft}
@@ -207,6 +245,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<input
type="text"
aria-label={t("ai.externalMcp.http.listenAddress")}
aria-invalid={addressError}
aria-describedby={addressError ? errorId : undefined}
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
value={addressDraft}
onChange={(event) => {
@@ -226,6 +266,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<input
type="text"
aria-label={t("ai.externalMcp.http.clientHost")}
aria-invalid={clientHostError}
aria-describedby={clientHostError ? errorId : undefined}
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
value={clientHostDraft}
onChange={(event) => {
@@ -245,8 +287,9 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
{settings.enabled && !externalMcpEnabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
) : null}
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
{errorText ? <p id={errorId} role="alert" className="text-xs text-destructive">{errorText}</p> : null}
<details className="space-y-4">
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.connectionDetails")}</summary>
<CopyableCodeBlock
label={t("ai.externalMcp.http.url")}
value={url}
@@ -259,6 +302,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<CopyableCodeBlock
label={t("ai.externalMcp.http.token")}
value={token}
sensitive
copyKey="http-token"
copied={copied}
onCopy={onCopy}
@@ -272,7 +316,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
type="button"
variant="outline"
size="sm"
disabled={!externalMcpEnabled || !settings.enabled || rotating}
disabled={!externalMcpEnabled || !settings.enabled || rotating || applying}
onClick={onRotateToken}
>
{t("ai.externalMcp.http.regenerate")}
@@ -290,6 +334,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<CopyableCodeBlock
label={t("ai.externalMcp.http.envLine")}
value={token ? buildCodexHttpEnvLine(token) : ""}
sensitive
copyKey="http-env"
copied={copied}
onCopy={onCopy}
@@ -300,6 +345,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<CopyableCodeBlock
label={t("ai.externalMcp.http.claudeCommand")}
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
sensitive
copyKey="http-claude"
copied={copied}
onCopy={onCopy}
@@ -310,6 +356,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<CopyableCodeBlock
label={t("ai.externalMcp.http.genericSnippet")}
value={token ? buildGenericHttpMcpJson(url, token) : ""}
sensitive
copyKey="http-json"
copied={copied}
onCopy={onCopy}
@@ -317,7 +364,14 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</div>
</div>
</details>
</ToolSettingsDialog>
</SettingRow>
</SettingCard>
{settings.enabled && !externalMcpEnabled ? <p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p> : null}
{applying ? <p role="status" className="text-xs text-muted-foreground">{t("ai.toolSettings.applyingHttp")}</p> : null}
{copyError ? <p role="alert" className="text-xs text-destructive">{copyError}</p> : null}
{operationError || status?.error ? <p role="alert" className="text-xs text-destructive">{operationError || status?.error}</p> : null}
</SettingsAnchor>
);
};
@@ -4,6 +4,7 @@ import { useI18n } from "../../../../application/i18n/I18nProvider";
import type { AIToolIntegrationMode } from "../../../../infrastructure/ai/types";
import { cn } from "../../../../lib/utils";
import { useToolAccessGuidanceState } from "../../../../application/state/useToolAccessGuidanceState";
import { ToolSettingsDialog } from "./ToolSettingsDialog";
import { EXTERNAL_MCP_DISCOVERY_ENV_VAR } from "./ExternalMcpCard";
/** Build a ready-to-paste prompt so an external AI client can register Netcatty MCP itself. */
@@ -45,16 +46,20 @@ type CopyRowProps = {
const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel, testId }) => {
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState(false);
const { t } = useI18n();
const canCopy = Boolean(value);
const handleCopy = async () => {
if (!value) return;
setCopyError(false);
setCopied(false);
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
} catch {
// Clipboard may be unavailable; the text stays selectable in the block.
setCopyError(true);
}
};
@@ -82,6 +87,7 @@ const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel,
{copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</button>
</div>
{copyError ? <p role="alert" className="text-xs text-destructive">{t("ai.externalMcp.copyFailed")}</p> : null}
</div>
);
};
@@ -93,7 +99,7 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
if (mode === "skills") {
return (
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
<ToolSettingsDialog title={t("ai.toolAccess.skills.file")} triggerLabel={t("ai.toolSettings.viewSkills")}>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.toolAccess.skills.description")}
</p>
@@ -110,12 +116,12 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
{commandPrefix ? (
<p className="text-xs text-muted-foreground/80 font-mono break-all">{commandPrefix}</p>
) : null}
</div>
</ToolSettingsDialog>
);
}
return (
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
<ToolSettingsDialog title={t("ai.toolAccess.mcpPrompt.title")} triggerLabel={t("ai.toolSettings.viewPrompt")}>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.toolAccess.mcpPrompt.description")}
</p>
@@ -129,6 +135,6 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
{!mcpLauncherPath ? (
<p className="text-xs text-amber-500">{t("ai.toolAccess.mcpPrompt.enableHint")}</p>
) : null}
</div>
</ToolSettingsDialog>
);
};
@@ -0,0 +1,47 @@
import React from "react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../../ui/dialog";
/** Shared, bounded settings dialog. Callers retain their original commit semantics. */
export const ToolSettingsDialog: React.FC<{
title: string;
triggerLabel: string;
children: React.ReactNode;
immediate?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
footer?: React.ReactNode;
busy?: boolean;
}> = ({ title, triggerLabel, children, immediate, open, onOpenChange, footer, busy }) => {
const { t } = useI18n();
const [localOpen, setLocalOpen] = React.useState(false);
const changeOpen = (next: boolean) => {
if (busy) return;
setLocalOpen(next);
onOpenChange?.(next);
};
return (
<Dialog open={open ?? localOpen} onOpenChange={changeOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">{triggerLabel}</Button>
</DialogTrigger>
<DialogContent
className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col gap-0 overflow-hidden p-0"
aria-busy={busy || undefined}
hideCloseButton={busy}
onEscapeKeyDown={(event) => { if (busy) event.preventDefault(); }}
onInteractOutside={(event) => { if (busy) event.preventDefault(); }}
>
<DialogHeader className="shrink-0 px-6 pb-4 pt-6 pr-12 text-left">
<DialogTitle>{title}</DialogTitle>
{immediate ? <p className="text-xs text-muted-foreground">{t("ai.toolSettings.immediate")}</p> : null}
</DialogHeader>
<div className="min-h-0 overflow-y-auto px-6 pb-6 space-y-4">{children}</div>
<DialogFooter className="shrink-0 gap-2 border-t px-6 py-4">
{footer ?? <Button type="button" variant="outline" onClick={() => changeOpen(false)}>{t("common.close")}</Button>}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,14 @@
import React from "react";
import { cn } from "../../../../lib/utils";
export type ToolStatusTone = "active" | "inactive" | "error";
/** Non-interactive runtime status, shared by the MCP and HTTP section headers. */
export const ToolStatusPill: React.FC<{ tone: ToolStatusTone; children: React.ReactNode }> = ({ tone, children }) => (
<span role="status" className={cn(
"inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium leading-5",
tone === "active" ? "bg-primary/10 text-primary"
: tone === "error" ? "bg-destructive/10 text-destructive"
: "bg-muted text-muted-foreground",
)}>{children}</span>
);
+4 -10
View File
@@ -1,14 +1,8 @@
import React from "react";
import { useI18n } from "../../application/i18n/I18nProvider";
import { formatMonitorRate as rate } from "../../domain/monitorFormatting";
function rate(value: number | null | undefined) {
if (value == null || !Number.isFinite(value) || value < 0) return "--";
if (value >= 1024 ** 2) return `${(value / 1024 ** 2).toFixed(1)} MiB/s`;
if (value >= 1024) return `${(value / 1024).toFixed(1)} KiB/s`;
return `${Math.round(value)} B/s`;
}
/** Same shared-sampler rates in monitor cards, tables, and terminal overview. */
/** Shared rate formatting for aggregate summaries and per-mount readings. */
export function DiskIoSummary({ stats }: {
stats?: { diskReadSpeed?: number | null; diskWriteSpeed?: number | null } | null;
}) {
@@ -16,8 +10,8 @@ export function DiskIoSummary({ stats }: {
return (
<div data-disk-io className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] tabular-nums text-muted-foreground" title={t("monitor.diskIoHint")}>
<span>{t("monitor.diskIo")}</span>
<span>{t("monitor.diskRead")} {rate(stats?.diskReadSpeed)}</span>
<span>{t("monitor.diskWrite")} {rate(stats?.diskWriteSpeed)}</span>
<span className="whitespace-nowrap">{t("monitor.diskRead")} {rate(stats?.diskReadSpeed)}</span>
<span className="whitespace-nowrap">{t("monitor.diskWrite")} {rate(stats?.diskWriteSpeed)}</span>
</div>
);
}
+4 -2
View File
@@ -7,6 +7,7 @@ interface ResourceBarProps {
className?: string;
/** Slightly taller track for GPU cards. */
size?: 'sm' | 'md';
danger?: boolean;
}
function barTone(clamped: number): string {
@@ -20,6 +21,7 @@ export const ResourceBar = memo(function ResourceBar({
value,
className,
size = 'sm',
danger = false,
}: ResourceBarProps) {
const finite = typeof value === 'number' && Number.isFinite(value);
const clamped = finite ? Math.max(0, Math.min(100, value)) : 0;
@@ -37,12 +39,12 @@ export const ResourceBar = memo(function ResourceBar({
<div
className={cn(
'h-full rounded-full transition-[width,background-color] duration-500 ease-out motion-reduce:transition-none',
finite ? barTone(clamped) : 'opacity-0',
finite ? (danger ? 'bg-destructive/80' : barTone(clamped)) : 'opacity-0',
)}
style={{ width: `${clamped}%` }}
/>
</div>
<span className="text-[10px] tabular-nums text-muted-foreground w-10 text-right shrink-0">
<span className={cn('text-[10px] tabular-nums w-10 text-right shrink-0', danger ? 'text-destructive font-medium' : 'text-muted-foreground')}>
{finite ? `${value.toFixed(1)}%` : '--'}
</span>
</div>
+1 -1
View File
@@ -1159,7 +1159,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
)}
</ToolbarCustomizeContextMenu>
{!compactToolbar && <PluginTerminalSidebarLauncher sessionId={sessionId} />}
<PluginTerminalSidebarLauncher sessionId={sessionId} buttonClassName={buttonBase} />
{pluginToolbarMenus.map((menu) => (
<Tooltip key={menu.id}>
<TooltipTrigger asChild>
@@ -18,7 +18,6 @@ import {
reorderTerminalSidePanelTab,
fitTerminalSidePanelTabs,
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
TERMINAL_SIDE_PANEL_TAB_IDS,
type TerminalSidePanelTabId,
useTerminalSidePanelTabOrder,
} from '../../application/state/terminalSidePanelTabs';
@@ -821,7 +820,11 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
move: moveSidePanelTab,
resetLayout: resetSidePanelTabLayout,
partition: partitionSidePanelTabs,
} = useTerminalSidePanelTabOrder();
} = useTerminalSidePanelTabOrder(pluginSidebar.pages.map((page) => page.tool));
const availableSidePanelTabs = useMemo(() => [
...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, ...pluginSidebar.pages.map((page) => page.tool),
], [pluginSidebar.pages]);
const availableSidePanelTabSet = useMemo(() => new Set<SidePanelTab>(availableSidePanelTabs), [availableSidePanelTabs]);
const resolvedSidePanelTerminalTheme = useMemo(() => (
followAppTerminalTheme
? terminalTheme
@@ -1019,7 +1022,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const draggedTab = draggedSidePanelTabRef.current ?? transferredTab;
draggedSidePanelTabRef.current = null;
setDragOverSidePanelTab(null);
if (!TERMINAL_SIDE_PANEL_TAB_IDS.has(draggedTab)) return;
if (!availableSidePanelTabSet.has(draggedTab)) return;
const nextOrder = reorderTerminalSidePanelTab(
sidePanelTabOrder,
@@ -1030,7 +1033,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
if (nextOrder !== sidePanelTabOrder) {
setSidePanelTabOrder(nextOrder);
}
}, [dragOverSidePanelTab, setSidePanelTabOrder, sidePanelTabOrder]);
}, [availableSidePanelTabSet, dragOverSidePanelTab, setSidePanelTabOrder, sidePanelTabOrder]);
const handleSidePanelTabDragOver = useCallback((event: React.DragEvent, targetTab: TerminalSidePanelTabId) => {
if (!Array.from(event.dataTransfer.types).includes(SIDE_PANEL_TAB_DRAG_MIME)) return;
@@ -1163,7 +1166,12 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
}, [focusedPaneHost, handleSplitSidePanelPane]);
const { shown: configuredShownSidePanelTabs, collapsed: configuredCollapsedSidePanelTabs } = useMemo(() => {
const parts = partitionSidePanelTabs(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER);
const partition = partitionSidePanelTabs(availableSidePanelTabs);
const parts = {
shown: partition.shown as SidePanelTab[],
collapsed: partition.collapsed as SidePanelTab[],
hidden: partition.hidden as SidePanelTab[],
};
// If an external path opens a hidden tab, still show its chip while active.
if (
activeSidePanelTab &&
@@ -1177,7 +1185,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
};
}
return parts;
}, [activeSidePanelTab, partitionSidePanelTabs]);
}, [activeSidePanelTab, availableSidePanelTabs, partitionSidePanelTabs]);
// Bottom dock spans the full layer width (below the workspace focus
// sidebar row), so measure tab overflow against the terminal layer itself.
const sidePanelTabFitWidth = isBottomDock ? terminalLayerWidth : shellWidth;
@@ -1198,7 +1206,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const sidePanelCustomizeItems = useMemo(
() =>
sidePanelTabOrder.map((tabId) => {
sidePanelTabOrder.filter((tabId) => availableSidePanelTabSet.has(tabId)).map((tabId) => {
const item = sidePanelTabItemById.get(tabId);
return {
id: tabId,
@@ -1206,7 +1214,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
icon: item?.icon,
};
}),
[sidePanelTabItemById, sidePanelTabOrder],
[availableSidePanelTabSet, sidePanelTabItemById, sidePanelTabOrder],
);
return (
@@ -1288,7 +1296,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const next = setSidePanelTabPlacement(
id,
placement,
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
availableSidePanelTabs,
);
// Only close when hide actually stuck (not reverted by requireReachable).
if (activeSidePanelTab === id && (next.placement[id] ?? 'show') === 'hide') {
@@ -1296,7 +1304,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
}
}}
onMove={(id, direction) =>
moveSidePanelTab(id, direction, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER)
moveSidePanelTab(id, direction, availableSidePanelTabs)
}
onReset={resetSidePanelTabLayout}
t={t}
@@ -1517,8 +1525,8 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const sessionId = pluginSidebar.sessionId!;
return <PersistentSidePanelPortal key={`${activeTabId}:${pane.id}:${sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={activeTabId} sessionId={sessionId}
focusOnReveal={activeSidePanelLayout?.focusedPaneId === pane.id}
adapter={pluginSidebar.contributions} getSession={ctx.getActiveTerminalSessionId}
showHeader={activePaneCount === 1}
getSession={ctx.getActiveTerminalSessionId}
unavailable={() => ctx.handleWithdrawPluginSidebarPane?.(activeTabId, pane.id, page.tool)}
focused={() => { if (activeTabStore.getActiveTabId() === activeTabId) ctx.handleFocusSidePanelPane?.(pane.id); }} />
</PersistentSidePanelPortal>;
@@ -0,0 +1,80 @@
import test from "node:test";
import assert from "node:assert/strict";
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { JSDOM } from "jsdom";
import { useMonitorDiskGroups } from "../../application/state/useMonitorDiskGroups";
import { I18nProvider } from "../../application/i18n/I18nProvider";
import { MonitorDiskUsage } from "./MonitorDiskUsage";
import { MonitorDiskIo } from "./MonitorDiskIo";
function Harness({ percent, hideCapacity = false }: { percent: number; hideCapacity?: boolean }) {
const groups = useMonitorDiskGroups();
const stats = { diskPercent: 20, disks: [
{ mountPoint: "/boot/efi", percent, used: 1, total: 2 },
{ mountPoint: "/", percent: 20, used: 20, total: 100 },
{ mountPoint: "/bd10", percent: 10, used: 1, total: 10 },
{ mountPoint: "/boot", percent: 30, used: 1, total: 3 },
{ mountPoint: "/bd02", percent: 10, used: 1, total: 10 },
] };
return <I18nProvider locale="en">{["a", "b"].map(id => {
const layout = groups.layoutFor(id, stats);
return <div key={id} data-host={id}>
{!hideCapacity && <MonitorDiskUsage stats={stats} listLayout={layout} onToggleSystem={() => groups.toggle(id)} />}
<MonitorDiskIo stats={stats} listLayout={layout} showSystemToggle={hideCapacity} onToggleSystem={() => groups.toggle(id)} />
</div>;
})}</I18nProvider>;
}
test("system disclosure is per host, aligns both columns, and reacts to the strict 80% threshold", async () => {
const dom = new JSDOM("<div id='root'></div>", { url: "http://localhost" });
const globals = { window: dom.window, document: dom.window.document, navigator: dom.window.navigator, IS_REACT_ACT_ENVIRONMENT: true };
const originals = Object.fromEntries(Object.keys(globals).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
const root = createRoot(dom.window.document.getElementById("root")!);
const host = (id: string) => dom.window.document.querySelector(`[data-host=${id}]`)!;
const button = (id: string) => host(id).querySelector<HTMLButtonElement>("[data-monitor-system-disks]")!;
const assertAligned = (id: string, expected: string[]) => {
const capacity = [...host(id).querySelectorAll("[data-monitor-disk]")].map(row => row.getAttribute("data-monitor-disk"));
const io = [...host(id).querySelectorAll("[data-monitor-disk-io]")].map(row => row.getAttribute("data-monitor-disk-io"));
assert.deepEqual(capacity, expected);
assert.deepEqual(io, expected);
assert.equal(host(id).querySelectorAll("[data-monitor-system-io-slot]").length, 1);
};
try {
await act(async () => root.render(<Harness percent={80} />));
assertAligned("a", ["/", "/bd02", "/bd10"]);
assert.equal(button("a").getAttribute("aria-expanded"), "false");
await act(async () => button("a").click());
assertAligned("a", ["/", "/bd02", "/bd10", "/boot", "/boot/efi"]);
assertAligned("b", ["/", "/bd02", "/bd10"]);
await act(async () => button("a").click());
await act(async () => root.render(<Harness percent={81} />));
for (const id of ["a", "b"]) {
assertAligned(id, ["/", "/bd02", "/bd10", "/boot", "/boot/efi"]);
assert.equal(button(id).getAttribute("aria-expanded"), "true");
assert.equal(button(id).disabled, true);
const alert = host(id).querySelector("[data-monitor-disk-alert]")!;
assert.equal(alert.getAttribute("data-monitor-disk"), "/boot/efi");
assert.match(alert.innerHTML, /bg-destructive\/80/);
assert.match(alert.innerHTML, /text-destructive/);
}
await act(async () => root.render(<Harness percent={79} />));
assertAligned("a", ["/", "/bd02", "/bd10"]);
assert.equal(host("a").querySelector("[data-monitor-disk-alert]"), null);
await act(async () => root.render(<Harness percent={79} hideCapacity />));
assert.equal(host("a").querySelector("[data-monitor-disk]"), null);
assert.equal(button("a").getAttribute("aria-expanded"), "false");
await act(async () => button("a").click());
assert.deepEqual([...host("a").querySelectorAll("[data-monitor-disk-io]")].map(row => row.getAttribute("data-monitor-disk-io")), ["/", "/bd02", "/bd10", "/boot", "/boot/efi"]);
assert.equal(button("b").getAttribute("aria-expanded"), "false");
} finally {
await act(async () => root.unmount());
for (const key of Object.keys(globals)) {
const descriptor = originals[key];
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
dom.window.close();
}
});
+59
View File
@@ -0,0 +1,59 @@
import React from "react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useI18n } from "../../application/i18n/I18nProvider";
import type { MonitorStats } from "../../domain/monitorRuntime";
import { formatMonitorRate } from "../../domain/monitorFormatting";
import { buildMonitorDiskListLayout, type MonitorDiskListLayout } from "../../domain/monitorDisks";
import { MonitorSystemDiskToggle } from "./MonitorSystemDiskToggle";
type DiskRates = { diskReadSpeed?: number | null; diskWriteSpeed?: number | null };
function Rates({ stats }: { stats?: DiskRates | null }) {
const { t } = useI18n();
return <>
<span className="inline-flex items-center gap-1 whitespace-nowrap">
<ArrowDown size={11} aria-hidden="true" className="shrink-0 text-sky-600 dark:text-sky-400" />
<span className="sr-only">{t("monitor.diskRead")} </span>
{formatMonitorRate(stats?.diskReadSpeed)}
</span>
<span className="inline-flex items-center gap-1 whitespace-nowrap">
<ArrowUp size={11} aria-hidden="true" className="shrink-0 text-violet-600 dark:text-violet-400" />
<span className="sr-only">{t("monitor.diskWrite")} </span>
{formatMonitorRate(stats?.diskWriteSpeed)}
</span>
</>;
}
/** Same mount order and row height as the adjacent capacity column. */
export function MonitorDiskIo({ stats, listLayout, showSystemToggle = false, onToggleSystem }: {
stats?: (Pick<MonitorStats, "disks"> & DiskRates) | null;
listLayout?: MonitorDiskListLayout;
showSystemToggle?: boolean;
onToggleSystem?: () => void;
}) {
const { t } = useI18n();
const layout = listLayout ?? buildMonitorDiskListLayout(stats);
return (
<div className="w-full min-w-0 space-y-1.5 text-[10px] tabular-nums text-muted-foreground">
{layout.rows.map((row, index) => {
if (row.kind === "system") return showSystemToggle
? <MonitorSystemDiskToggle key="system" row={row} onToggle={onToggleSystem} />
: <div key="system" data-monitor-system-io-slot aria-hidden="true" className="h-4" />;
const { disk } = row;
return (
<div key={`${disk.mountPoint}-${index}`} data-monitor-disk-io={disk.mountPoint}
className="grid h-4 grid-cols-2 items-center gap-2"
title={`${disk.mountPoint}\n${t("monitor.partitionIoHint")}`}>
<span className="sr-only">{disk.mountPoint} </span>
<Rates stats={disk} />
</div>
);
})}
<div data-monitor-disk-io-total className="flex h-5 items-center justify-between gap-2 border-t border-border/40 pt-1"
title={t("monitor.diskIoHint")}>
<span>{t("monitor.diskIoTotal")}</span>
<div className="flex items-center gap-2"><Rates stats={stats} /></div>
</div>
</div>
);
}
+25 -14
View File
@@ -1,7 +1,9 @@
import React from "react";
import { DiskIoSummary } from "../systemManager/DiskIoSummary";
import type { MonitorStats } from "../../domain/monitorRuntime";
import { buildMonitorDiskListLayout, type MonitorDiskListLayout, type MonitorDiskListRow } from "../../domain/monitorDisks";
import { ResourceBar } from "../systemManager/ResourceBar";
import { DiskIoSummary } from "../systemManager/DiskIoSummary";
import { MonitorSystemDiskToggle } from "./MonitorSystemDiskToggle";
import { useI18n } from "../../application/i18n/I18nProvider";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
@@ -11,35 +13,43 @@ const diskCapacity = (value: number) => value >= 1024
? `${(value * 1024).toFixed(1)} MiB`
: `${value.toFixed(1)} GiB`;
/** Cards show the largest capacities; the list keeps every reported mount. */
/** Cards show the largest capacities; the list puts system mounts first. */
export function MonitorDiskUsage({
stats,
limit,
listLayout,
onToggleSystem,
}: {
stats?: (Pick<MonitorStats, "disks" | "diskPercent"> & Partial<Pick<MonitorStats, "diskUsed" | "diskTotal" | "diskReadSpeed" | "diskWriteSpeed">>) | null;
limit?: number;
listLayout?: MonitorDiskListLayout;
onToggleSystem?: () => void;
}) {
const { t } = useI18n();
const reported = stats?.disks.length
? stats.disks
: [{ mountPoint: "/", percent: stats?.diskPercent ?? null, used: stats?.diskUsed ?? null, total: stats?.diskTotal ?? null }];
const disks = limit == null
? reported
: [...reported].sort((a, b) =>
("total" in b ? b.total : 0) - ("total" in a ? a.total : 0),
).slice(0, limit);
const rows: MonitorDiskListRow[] = limit == null ? (listLayout ?? buildMonitorDiskListLayout(stats)).rows
: [...reported].sort((a, b) => (b.total ?? 0) - (a.total ?? 0))
.slice(0, limit).map(disk => ({ kind: "disk", disk, alert: false }));
const bareRoot = limit == null && rows.length === 1 && rows[0].kind === "disk" && rows[0].disk.mountPoint === "/";
return (
<TooltipProvider delayDuration={150}>
<div className={limit === 2 ? "min-h-[38px] space-y-1.5" : "space-y-1.5"}>
<DiskIoSummary stats={stats} />
{disks.map((disk, index) => (
{limit != null && <DiskIoSummary stats={stats} />}
{rows.map((row, index) => {
if (row.kind === "system") return (
<MonitorSystemDiskToggle key="system" row={row} onToggle={onToggleSystem} />
);
const { disk, alert } = row;
return (
<Tooltip key={`${disk.mountPoint}-${index}`}>
<TooltipTrigger asChild>
<div data-monitor-disk={disk.mountPoint} className="flex min-w-0 items-center gap-2">
<span className="w-24 shrink-0 truncate text-[10px] text-muted-foreground">
<div data-monitor-disk={disk.mountPoint} data-monitor-disk-alert={alert || undefined} className={`${bareRoot ? "min-w-0" : "flex h-4 min-w-0 items-center gap-2"} ${alert ? "rounded bg-destructive/10" : ""}`}>
{!bareRoot && <span data-monitor-disk-label className={`w-24 shrink-0 truncate text-[10px] ${alert ? "font-medium text-destructive" : "text-muted-foreground"}`}>
{disk.mountPoint}
</span>
<ResourceBar label="" value={disk.percent} className="flex-1" />
</span>}
<ResourceBar label="" value={disk.percent} className={bareRoot ? "h-4 w-full" : "flex-1"} danger={alert} />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-80 bg-popover text-popover-foreground shadow-md ring-1 ring-border/40">
@@ -51,7 +61,8 @@ export function MonitorDiskUsage({
</div>
</TooltipContent>
</Tooltip>
))}
);
})}
</div>
</TooltipProvider>
);
Loaded 100 of 313 files, more files were not shown because too many files have changed in this diff. Show more