mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 11:22:54 +00:00
Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ba1b7b34a | ||
|
|
c309a455ed | ||
|
|
39a0008876 | ||
|
|
f5a802d610 | ||
|
|
7d5d234fdd | ||
|
|
eb56936e9b | ||
|
|
6da42ebc53 | ||
|
|
f0483488e1 | ||
|
|
263133f2ea | ||
|
|
887c5779b5 | ||
|
|
27f26d8e69 | ||
|
|
30a74f7c1d | ||
|
|
631d04b654 |
No files matched your search
@@ -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/
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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...",
|
||||
};
|
||||
@@ -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.',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
};
|
||||
@@ -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.',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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...",
|
||||
};
|
||||
@@ -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': 'Включённые плагины не предоставляют настройки.',
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.systemDisks": "Системные разделы ({count})",
|
||||
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
|
||||
"monitor.diskIoTotal": "Всего",
|
||||
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
|
||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||
"monitor.diskRead": "Чтение",
|
||||
"monitor.diskWrite": "Запись",
|
||||
|
||||
@@ -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 配置…",
|
||||
};
|
||||
@@ -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": "写入",
|
||||
|
||||
@@ -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 設定…",
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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: [] });
|
||||
});
|
||||
@@ -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; },
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>));
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>)}</>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
)}</>;
|
||||
}
|
||||
@@ -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, /<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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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, /<script>/);
|
||||
});
|
||||
|
||||
test('README only renders registered local rasterized images and placeholders otherwise', () => {
|
||||
const path = `/assets/${'a'.repeat(64)}.png`;
|
||||
const html = renderToStaticMarkup(<PluginReadme markdown={`  `} 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/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user