mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-03 20:42:33 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dc3fc3cd1 | ||
|
|
bb02f5cbd0 | ||
|
|
a6dda1771c | ||
|
|
6620b85f2d | ||
|
|
942f106240 | ||
|
|
0c0d2ca558 |
No files matched your search
@@ -170,6 +170,3 @@ 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,11 +127,6 @@ 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,7 +16,6 @@ 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>;
|
||||
@@ -265,7 +264,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
|
||||
}
|
||||
}
|
||||
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || isPluginViewEditingTarget(target);
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
|
||||
@@ -59,7 +59,6 @@ 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';
|
||||
@@ -1157,8 +1156,6 @@ 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)),
|
||||
|
||||
@@ -161,12 +161,7 @@ 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.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.tempDirectoryHint': 'Temporary files are created when opening remote files with external applications. They are automatically cleaned up when SFTP sessions close.',
|
||||
'settings.system.credentials.title': 'Credential Protection',
|
||||
'settings.system.credentials.status': 'Status',
|
||||
'settings.system.credentials.checking': 'Checking...',
|
||||
@@ -1380,9 +1375,6 @@ 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",
|
||||
@@ -1401,7 +1393,6 @@ 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,8 +1,4 @@
|
||||
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",
|
||||
|
||||
@@ -161,12 +161,7 @@ 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.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.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.credentials.title': 'Protección de credenciales',
|
||||
'settings.system.credentials.status': 'Estado',
|
||||
'settings.system.credentials.checking': 'Comprobando...',
|
||||
@@ -1380,9 +1375,6 @@ 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",
|
||||
@@ -1401,7 +1393,6 @@ 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,8 +1,4 @@
|
||||
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",
|
||||
|
||||
@@ -134,12 +134,7 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': 'Очистить временные файлы',
|
||||
'settings.system.clearing': 'Очистка...',
|
||||
'settings.system.clearResult': 'Удалено файлов: {deleted}, ошибок: {failed}.',
|
||||
'settings.system.tempDirChoose': 'Выбрать папку',
|
||||
'settings.system.tempDirManual': 'Введите абсолютный путь к папке',
|
||||
'settings.system.tempDirApply': 'Применить',
|
||||
'settings.system.tempDirReset': 'Сбросить по умолчанию',
|
||||
'settings.system.tempDirError': 'Не удалось использовать эту папку.',
|
||||
'settings.system.tempDirectoryHint': 'Используется для промежуточного хранения SFTP-передач и файлов, открытых во внешних приложениях. Смена папки во время передачи может повлиять на эту передачу.',
|
||||
'settings.system.tempDirectoryHint': 'Временные файлы создаются при открытии удалённых файлов во внешних приложениях. Они автоматически очищаются при закрытии SFTP-сессий.',
|
||||
'settings.system.credentials.title': 'Защита учётных данных',
|
||||
'settings.system.credentials.status': 'Статус',
|
||||
'settings.system.credentials.checking': 'Проверка...',
|
||||
@@ -1245,9 +1240,6 @@ 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",
|
||||
@@ -1266,7 +1258,6 @@ 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,8 +1,4 @@
|
||||
export const ruMonitorMessages = {
|
||||
"monitor.systemDisks": "Системные разделы ({count})",
|
||||
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
|
||||
"monitor.diskIoTotal": "Всего",
|
||||
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
|
||||
"monitor.diskIo": "Дисковый ввод-вывод",
|
||||
"monitor.diskRead": "Чтение",
|
||||
"monitor.diskWrite": "Запись",
|
||||
|
||||
@@ -165,12 +165,7 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': '清理临时文件',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已删除 {deleted} 个文件,{failed} 个失败。',
|
||||
'settings.system.tempDirChoose': '选择文件夹',
|
||||
'settings.system.tempDirManual': '输入绝对文件夹路径',
|
||||
'settings.system.tempDirApply': '应用',
|
||||
'settings.system.tempDirReset': '恢复默认',
|
||||
'settings.system.tempDirError': '无法使用此文件夹。',
|
||||
'settings.system.tempDirectoryHint': 'Netcatty 在所选文件夹中创建 Netcatty 临时子文件夹,用于 SFTP 传输暂存和外部编辑文件。传输期间更改文件夹可能影响该传输。',
|
||||
'settings.system.tempDirectoryHint': '临时文件在使用外部应用打开远程文件时创建。SFTP 会话关闭时会自动清理。',
|
||||
'settings.system.credentials.title': '凭据保护',
|
||||
'settings.system.credentials.status': '状态',
|
||||
'settings.system.credentials.checking': '检查中...',
|
||||
@@ -1295,9 +1290,6 @@ 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": "管理来源",
|
||||
@@ -1316,7 +1308,6 @@ 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,8 +1,4 @@
|
||||
export const zhCNMonitorMessages = {
|
||||
"monitor.systemDisks": "系统分区({count})",
|
||||
"monitor.systemDisksAlert": "系统分区使用率超过 80%,已自动展开。",
|
||||
"monitor.diskIoTotal": "汇总",
|
||||
"monitor.partitionIoHint": "此挂载点对应块设备的读写速率;共用设备的挂载点显示相同速率。取得两次有效采样前或无法识别块设备时显示 --;暂不支持 macOS。",
|
||||
"monitor.diskIo": "磁盘 I/O",
|
||||
"monitor.diskRead": "读取",
|
||||
"monitor.diskWrite": "写入",
|
||||
|
||||
@@ -167,12 +167,7 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.system.clearTempFiles': '清理臨時檔案',
|
||||
'settings.system.clearing': '清理中...',
|
||||
'settings.system.clearResult': '已刪除 {deleted} 個檔案,{failed} 個失敗。',
|
||||
'settings.system.tempDirChoose': '選擇資料夾',
|
||||
'settings.system.tempDirManual': '輸入絕對資料夾路徑',
|
||||
'settings.system.tempDirApply': '套用',
|
||||
'settings.system.tempDirReset': '恢復預設',
|
||||
'settings.system.tempDirError': '無法使用此資料夾。',
|
||||
'settings.system.tempDirectoryHint': '用於 SFTP 傳輸暫存與外部編輯檔案。傳輸期間變更資料夾可能影響該傳輸。',
|
||||
'settings.system.tempDirectoryHint': '臨時檔案在使用外部應用程式開啟遠端檔案時建立。SFTP 工作階段關閉時會自動清理。',
|
||||
'settings.system.credentials.title': '憑證保護',
|
||||
'settings.system.credentials.status': '狀態',
|
||||
'settings.system.credentials.checking': '檢查中...',
|
||||
@@ -1266,9 +1261,6 @@ 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": "管理来源",
|
||||
@@ -1287,7 +1279,6 @@ 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,8 +1,4 @@
|
||||
export const zhTWMonitorMessages = {
|
||||
"monitor.systemDisks": "系統分割區({count})",
|
||||
"monitor.systemDisksAlert": "系統分割區使用率超過 80%,已自動展開。",
|
||||
"monitor.diskIoTotal": "彙總",
|
||||
"monitor.partitionIoHint": "此掛載點對應區塊裝置的讀寫速率;共用裝置的掛載點顯示相同速率。取得兩次有效取樣前或無法識別區塊裝置時顯示 --;暫不支援 macOS。",
|
||||
"monitor.diskIo": "磁碟 I/O",
|
||||
"monitor.diskRead": "讀取",
|
||||
"monitor.diskWrite": "寫入",
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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(); }
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/** 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,7 +33,6 @@ const NAMED_KEYS = new Map<string, string>([
|
||||
['tab', 'tab'],
|
||||
]);
|
||||
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
|
||||
'[data-plugin-editing]',
|
||||
'input',
|
||||
'textarea',
|
||||
'select',
|
||||
@@ -45,10 +44,6 @@ 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'
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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: [] });
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
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,7 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
|
||||
import { PluginViewTabStore } from './pluginViewTabStore';
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
|
||||
const previous = {
|
||||
@@ -80,3 +184,30 @@ 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,3 +1,11 @@
|
||||
export interface HostedPluginViewState {
|
||||
id: string;
|
||||
viewId: string;
|
||||
scopeId: string;
|
||||
retainContextWhenHidden: boolean;
|
||||
tabId?: string;
|
||||
}
|
||||
|
||||
export interface PluginViewSnapshotSelection<T> {
|
||||
requestViewId: string;
|
||||
contextKey: string;
|
||||
@@ -73,3 +81,241 @@ 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;
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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();
|
||||
});
|
||||
@@ -1,78 +0,0 @@
|
||||
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();
|
||||
@@ -1,44 +0,0 @@
|
||||
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, useEffect, useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
normalizeToolbarItemLayout,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
||||
import { isPluginSidePanelTool, type BuiltinSidePanelTool, type SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
|
||||
export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
@@ -101,7 +101,7 @@ function migrateSidePanelTabLayoutIfNeeded(): void {
|
||||
|
||||
migrateSidePanelTabLayoutIfNeeded();
|
||||
|
||||
export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool[] = []): {
|
||||
export function useTerminalSidePanelTabOrder(): {
|
||||
sidePanelTabOrder: TerminalSidePanelTabId[];
|
||||
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
|
||||
layout: ToolbarItemLayout;
|
||||
@@ -111,23 +111,6 @@ export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool
|
||||
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,
|
||||
@@ -138,28 +121,9 @@ export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool
|
||||
partition,
|
||||
} = useToolbarItemLayout(
|
||||
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
|
||||
defaults,
|
||||
TERMINAL_SIDE_PANEL_TAB_LAYOUT_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(
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
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,7 +10,6 @@ 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);
|
||||
@@ -52,19 +51,9 @@ 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,8 +86,14 @@ 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(
|
||||
@@ -183,6 +189,32 @@ 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);
|
||||
@@ -191,6 +223,10 @@ 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,
|
||||
@@ -209,7 +245,13 @@ export function usePluginContributions(
|
||||
updateSetting,
|
||||
resetSetting,
|
||||
selectSettingPath,
|
||||
openView,
|
||||
closeView,
|
||||
focusView,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
setEnvironment,
|
||||
onViewClosed,
|
||||
onViewFocused,
|
||||
};
|
||||
}
|
||||
@@ -38,20 +38,18 @@ 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>, throwOnError = false) => {
|
||||
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
|
||||
if (mutation.current) return;
|
||||
mutation.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
let failure: Error | null = null;
|
||||
try { await action(netcattyBridge.require()); }
|
||||
catch (cause) { failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
|
||||
finally {
|
||||
try { await refresh(); } catch (cause) { failure ??= cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
|
||||
try { await refresh(); } catch (cause) { setError(String(cause)); }
|
||||
mutation.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
if (throwOnError && failure) throw failure;
|
||||
}, [refresh]);
|
||||
return {
|
||||
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
|
||||
@@ -72,6 +70,6 @@ export function usePluginManager() {
|
||||
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
|
||||
await bridge.resetPluginSource(id, source.id);
|
||||
}
|
||||
}, true),
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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,12 +14,19 @@ import {
|
||||
usePluginViewTabs,
|
||||
} from './pluginViewTabStore';
|
||||
import {
|
||||
PluginViewLifecycleController,
|
||||
reconcilePluginViewTabCatalog,
|
||||
resolvePluginViewSnapshotSelection,
|
||||
type HostedPluginViewState,
|
||||
type PluginViewSnapshotSelection,
|
||||
} from './pluginViewLifecycle';
|
||||
import { pluginViewSessions } from './pluginViewSession';
|
||||
import {
|
||||
canRetainPluginViewInScope,
|
||||
resolvePluginRetainedViewKey,
|
||||
resolvePluginViewWindowScope,
|
||||
} from './pluginViewScopes';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
|
||||
|
||||
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
|
||||
|
||||
@@ -63,8 +70,17 @@ 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,
|
||||
@@ -77,6 +93,16 @@ 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
|
||||
@@ -88,6 +114,26 @@ 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)
|
||||
@@ -136,16 +182,164 @@ export function usePluginViewLifecycle({
|
||||
setRequested(null);
|
||||
}, [activeView, requested]);
|
||||
|
||||
useEffect(() => {
|
||||
const stop = pluginViewTabStore.onDidClose(({ tab }) => pluginViewSessions.closeView(tab.viewId));
|
||||
return () => { stop(); };
|
||||
}, []);
|
||||
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]);
|
||||
|
||||
const close = useCallback(async () => {
|
||||
lifecycleRef.current.markViewClosed(retainedViewKey);
|
||||
const current = lifecycleRef.current.takeCurrent();
|
||||
setInstance(null);
|
||||
setRequested(null);
|
||||
if (activeView) pluginViewSessions.closeView(activeView.view.id);
|
||||
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
|
||||
}, [activePluginTab, activeView]);
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contributions.available) return;
|
||||
@@ -182,10 +376,16 @@ 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,
|
||||
viewQueryContext,
|
||||
mountRef,
|
||||
};
|
||||
}
|
||||
@@ -2,8 +2,6 @@ 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;
|
||||
|
||||
@@ -63,9 +63,6 @@ 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,15 +208,13 @@ 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 applyLocalVaultPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined }, {
|
||||
prepareConvergentRestore: async () => async () => {},
|
||||
});
|
||||
await applySyncPayload(remotePayload(remove ? [] : [incoming]), { importVaultData: () => undefined });
|
||||
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.equal(getAISessionsSnapshot().sessions[0].messages.at(-1)?.content, 'Remote');
|
||||
else assert.ok(getAISessionsSnapshot().sessions[0].messages.some(message => message.content === 'Settled'));
|
||||
wait.mock.restore();
|
||||
globalThis.localStorage.setItem = original;
|
||||
await act(async () => { hook!.setStreamingForScope('chat', false); });
|
||||
@@ -228,44 +226,6 @@ 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,8 +1275,6 @@ 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);
|
||||
@@ -1284,29 +1282,26 @@ 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) && !preserveActive(session.id)).map((session) => session.id);
|
||||
const removedIds = localSessions.filter((session) => !incomingIds.has(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);
|
||||
// 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;
|
||||
const mergedSessions = candidates.map((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', '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'));
|
||||
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'));
|
||||
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, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: {
|
||||
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'netcattyView', { value: {
|
||||
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
|
||||
}, views: { onDidReceiveMessage: () => ({ dispose() {} }) }
|
||||
onDidReceiveMessage: () => ({ dispose() {} }),
|
||||
} });
|
||||
diagnosticPhase = 'initial-selection';
|
||||
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 act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
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.disposePanel(); panel.window.close(); }
|
||||
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); 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', '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'));
|
||||
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'));
|
||||
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, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
|
||||
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: { executeCommand: async (name: string, params: unknown) => {
|
||||
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) => {
|
||||
// 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;
|
||||
} }, 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");'));
|
||||
}, onDidReceiveMessage: () => ({ dispose() {} }), onDidChangeEnvironment: () => ({ dispose() {} }) } });
|
||||
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
|
||||
};
|
||||
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.disposePanel(); panel!.window.close();
|
||||
panel!.window.dispatchEvent(new panel!.window.Event('pagehide')); 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.disposePanel(); panel.window.close(); }
|
||||
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); 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,27 +5,6 @@ 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>();
|
||||
@@ -46,6 +25,7 @@ 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', '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'));
|
||||
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'));
|
||||
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,12 +14,11 @@ 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);
|
||||
const rectX = 200;
|
||||
let 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');
|
||||
@@ -31,17 +30,22 @@ 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', visible: true };
|
||||
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', 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>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [];
|
||||
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>();
|
||||
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 () => {}; }, onHostDrawnPluginViewUpdate() { return () => {}; },
|
||||
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closeHostDrawnPluginView() {},
|
||||
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); },
|
||||
} });
|
||||
let split!: () => void;
|
||||
function Fixture() {
|
||||
@@ -71,44 +75,54 @@ 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} showHeader={panes.length === 1} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
|
||||
<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)} />
|
||||
</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"><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 () => { 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 () => { 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);
|
||||
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(); });
|
||||
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
|
||||
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
|
||||
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
|
||||
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession }); await settle(); });
|
||||
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(); });
|
||||
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 () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
|
||||
await act(async () => { rectX = 260; 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}`);
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
|
||||
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(); });
|
||||
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
|
||||
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
|
||||
const localPage = document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!;
|
||||
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'))!;
|
||||
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 import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
|
||||
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); }
|
||||
}
|
||||
});
|
||||
@@ -21,19 +21,24 @@ 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', visible: true };
|
||||
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', 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[] = [];
|
||||
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();
|
||||
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); }; },
|
||||
onHostDrawnPluginViewUpdate() { return () => {}; },
|
||||
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closeHostDrawnPluginView(id: string) { closes.push(id); },
|
||||
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(); },
|
||||
} });
|
||||
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
|
||||
function Fixture() {
|
||||
@@ -53,26 +58,44 @@ 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');
|
||||
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); await settle(); });
|
||||
assert.equal(closes.length, 0, 'hiding keeps the logical view available');
|
||||
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('vault'); await settle(); });
|
||||
assert.equal(opens.length, 1, 'returning to Vault reuses the same plugin state');
|
||||
assert.equal(opens.length, 2);
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
|
||||
await act(async () => { await settle(); });
|
||||
// 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.ok(closes.includes(opens[1].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, 2);
|
||||
assert.equal(opens.length, 3);
|
||||
await act(async () => { selectSection('hosts'); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
|
||||
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');
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
|
||||
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); }
|
||||
}
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Globe, HardDrive, Store } from 'lucide-react';
|
||||
import { UserRound } 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, showSource = false }: {
|
||||
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string; showSource?: boolean;
|
||||
export function PluginAuthorRow({ entry, plugin, sourceId }: {
|
||||
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { ref, identity } = usePluginAuthorIdentity({ entry, plugin, sourceId });
|
||||
@@ -14,22 +14,13 @@ export function PluginAuthorRow({ entry, plugin, sourceId, showSource = false }:
|
||||
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;
|
||||
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>}
|
||||
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>}
|
||||
</div>;
|
||||
}
|
||||
@@ -7,12 +7,9 @@ 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({
|
||||
@@ -31,7 +28,7 @@ export function PluginContributionHost({
|
||||
activeView,
|
||||
close,
|
||||
effectiveRequested,
|
||||
viewQueryContext,
|
||||
mountRef,
|
||||
} = usePluginViewLifecycle({
|
||||
locale,
|
||||
theme,
|
||||
@@ -41,24 +38,18 @@ 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-40 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-50 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} 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 className={`${containerClass} flex flex-col`} role="region" aria-label={activeView.view.title}>
|
||||
<div ref={mountRef} className="min-h-0 flex-1" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +58,6 @@ 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}
|
||||
>
|
||||
@@ -90,9 +80,7 @@ export function PluginContributionHost({
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</header>
|
||||
<div className={location === 'modal' ? 'flex min-h-0 flex-1 p-3' : 'flex min-h-0 flex-1'}>
|
||||
{content}
|
||||
</div>
|
||||
<div ref={mountRef} className="min-h-0 flex-1" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
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); }
|
||||
}
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
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>;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
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} />;
|
||||
}
|
||||
@@ -1,275 +0,0 @@
|
||||
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 { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
export function PluginTerminalSidebarLauncher({ sessionId, buttonClassName = 'h-6 w-6' }: { sessionId: string; buttonClassName?: string }) {
|
||||
const { locale } = useI18n();
|
||||
export function PluginTerminalSidebarLauncher({ sessionId }: { sessionId: string }) {
|
||||
const { locale, t } = useI18n();
|
||||
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
|
||||
const pages = collectPluginTerminalSidebarPages(contributions.snapshot.plugins);
|
||||
if (!pages.length) return null;
|
||||
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}`}
|
||||
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"
|
||||
onClick={() => requestOpenPluginTerminalSidebar(page.viewId, sessionId)}>
|
||||
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{page.title} — {page.pluginName}</TooltipContent>
|
||||
</Tooltip>)}</>;
|
||||
<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>;
|
||||
}
|
||||
|
||||
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, getSession, unavailable, focused, showHeader = true }: {
|
||||
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter, getSession, unavailable, focused, focusOnReveal }: {
|
||||
page: PluginTerminalSidebarPage;
|
||||
tabId: string;
|
||||
sessionId: string;
|
||||
adapter: ReturnType<typeof usePluginContributions>;
|
||||
getSession(): string | null;
|
||||
unavailable(): void;
|
||||
focused(): void;
|
||||
showHeader?: boolean;
|
||||
focusOnReveal: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const subscribeHidden = useMemo(() => (hide: () => void) => {
|
||||
@@ -42,8 +42,15 @@ export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, getSessi
|
||||
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>
|
||||
{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>
|
||||
<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>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,34 +1,44 @@
|
||||
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 <>
|
||||
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>}
|
||||
{model.pages.map((page) => <Button key={page.key} type="button"
|
||||
variant={model.selected?.key === page.key ? 'secondary' : 'ghost'}
|
||||
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' : ''}`}
|
||||
className={`w-full ${collapsed ? 'justify-center px-0' : 'justify-start gap-3'}`}
|
||||
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} size={16} className="shrink-0" />
|
||||
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} 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}>
|
||||
<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')} />
|
||||
<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>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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,
|
||||
)}</>;
|
||||
}
|
||||
@@ -12,53 +12,14 @@ 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 show only the author without decoration or an official badge', () => {
|
||||
test('untrusted entry strings and absent imagery produce a compact placeholder without 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.doesNotMatch(html, /<img|<svg|data-plugin-author-placeholder|bg-muted|rounded-full|data-plugin-official-maintainer/);
|
||||
assert.match(html, /data-plugin-author-placeholder/);
|
||||
assert.doesNotMatch(html, /<img|data-plugin-official-maintainer/);
|
||||
assert.match(html, /<img/);
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
assert.match(html, /items-center/);
|
||||
});
|
||||
|
||||
test('mounted author rows load only when visible, retain fallbacks, cancel stale results and show a borderless themed maintainer pill', async () => {
|
||||
@@ -97,13 +58,12 @@ test('mounted author rows load only when visible, retain fallbacks, cancel stale
|
||||
});
|
||||
};
|
||||
try {
|
||||
await render(); assert.equal(calls.length, 0); assert.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
|
||||
await render(); assert.equal(calls.length, 0); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
|
||||
await visible(); assert.deepEqual(calls[0], { kind: 'catalog', pluginId: entry.id, selectionToken: entry.selectionToken, sourceId: 'official' });
|
||||
assert.equal(document.querySelector('img'), null, 'loading does not reserve an empty avatar');
|
||||
assert.equal(document.querySelector('[data-plugin-author]')?.textContent, 'Netcatty');
|
||||
assert.ok(document.querySelector('[data-plugin-author-placeholder]'), 'loading uses the default placeholder');
|
||||
await act(async () => pending.shift()!(identity));
|
||||
const image = document.querySelector('img')!;
|
||||
assert.equal(image.getAttribute('src'), identity.avatar); assert.equal(image.width, 16); assert.equal(image.height, 16);
|
||||
assert.equal(image.getAttribute('src'), identity.avatar); assert.equal(image.width, 20); assert.equal(image.height, 20);
|
||||
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'));
|
||||
@@ -112,7 +72,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.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
|
||||
assert.equal(document.querySelector('img'), null); assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
|
||||
assert.ok(document.querySelector('[data-plugin-official-maintainer]'), 'image failure does not alter maintenance identity');
|
||||
|
||||
const next = { ...entry, selectionToken: 'c'.repeat(64) };
|
||||
@@ -129,7 +89,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.equal(document.querySelector('[data-plugin-author-placeholder]'), null);
|
||||
assert.ok(document.querySelector('[data-plugin-author-placeholder]'));
|
||||
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, readmeImages: {}, 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, 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 { CircleAlert, Info } from 'lucide-react';
|
||||
import { 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, variant = 'info' }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode; variant?: 'info' | 'error' }) {
|
||||
export function PluginInfo({ label, children, badgeCount, previewContent }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode }) {
|
||||
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, varian
|
||||
<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 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 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>
|
||||
</PopoverTrigger></TooltipTrigger>
|
||||
<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>
|
||||
<TooltipContent className="max-w-xs space-y-2 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-h-64 max-w-xs space-y-2 overflow-y-auto break-words text-xs leading-5">{children}</PopoverContent>
|
||||
<PopoverContent onEscapeKeyDown={() => { suppressFocusPreview.current = true; }} aria-label={label} align="end" className="max-w-xs space-y-2 text-xs leading-5">{children}</PopoverContent>
|
||||
</Popover>;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -8,12 +7,6 @@ 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;
|
||||
@@ -41,13 +34,10 @@ 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.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(authorRow.findByProps({ 'data-plugin-author-placeholder': true }), 'an unverified installed publisher has a default avatar');
|
||||
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.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.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
|
||||
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());
|
||||
@@ -71,11 +61,5 @@ 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;
|
||||
dom.window.close();
|
||||
for (const [key, descriptor] of previousDom) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
|
||||
});
|
||||
@@ -38,12 +38,6 @@ 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);
|
||||
@@ -66,6 +60,20 @@ 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);
|
||||
@@ -90,8 +98,6 @@ 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, { useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
|
||||
@@ -10,30 +10,41 @@ export function PluginManagementDialog({ children, trigger, open: controlledOpen
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
restoreFocusTo?: React.RefObject<HTMLElement | null>;
|
||||
fallbackFocusTo?: React.RefObject<HTMLElement | null>;
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode | ((handoff: (action: () => void) => void) => 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 previous = restoreFocusTo?.current;
|
||||
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
|
||||
if (target?.isConnected) {
|
||||
const action = pendingHandoff.current;
|
||||
pendingHandoff.current = null;
|
||||
if (action) {
|
||||
event.preventDefault();
|
||||
target.focus({ preventScroll: true });
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
}} className="app-no-drag flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
|
||||
}} className="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">
|
||||
{children}
|
||||
{typeof children === 'function' ? children(handoff) : 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, readmeImages: {}, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
|
||||
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, 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} images={catalog.readmeImages} /> : <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} /> : <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.market.emptyReadme')}</p>)}
|
||||
</div>
|
||||
</article>}
|
||||
</div>
|
||||
|
||||
@@ -3,29 +3,7 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { I18nProvider } from '../../../application/i18n/I18nProvider';
|
||||
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();
|
||||
}
|
||||
});
|
||||
import { PluginPackageDetails } from './PluginPackageManager';
|
||||
|
||||
test('package detail renders manifest resources as text and explains advanced authority', () => {
|
||||
const plugin = {
|
||||
|
||||
@@ -9,8 +9,6 @@ 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();
|
||||
@@ -52,7 +50,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId, detailActions }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void; detailActions?: React.ReactNode }) {
|
||||
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -79,7 +77,6 @@ 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>}
|
||||
@@ -92,18 +89,10 @@ 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>
|
||||
<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)}>
|
||||
<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="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}
|
||||
@@ -113,39 +102,24 @@ 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>
|
||||
<PluginAuthorRow plugin={plugin} showSource />
|
||||
<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} />}
|
||||
<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(); }}
|
||||
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>}
|
||||
/>
|
||||
<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>}
|
||||
</>}
|
||||
</article>)}
|
||||
</div>
|
||||
|
||||
@@ -7,10 +7,9 @@ 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, actions }: {
|
||||
export function PluginPermissionPanel({ pluginId, onChanged }: {
|
||||
pluginId: string;
|
||||
onChanged: () => Promise<void>;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const [snapshot, setSnapshot] = useState<NetcattyPluginPermissionSnapshot | null>(null);
|
||||
@@ -103,10 +102,7 @@ export function PluginPermissionPanel({ pluginId, onChanged, actions }: {
|
||||
</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,12 +12,3 @@ 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, images: Record<string, string>, depth = 0): React.ReactNode {
|
||||
function renderNode(node: Root | RootContent, key: number, depth = 0): React.ReactNode {
|
||||
if (depth > 24) return null;
|
||||
const children = 'children' in node ? node.children.map((child, index) => renderNode(child as RootContent, index, images, depth + 1)) : null;
|
||||
const children = 'children' in node ? node.children.map((child, index) => renderNode(child as RootContent, index, 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,19 +24,14 @@ function renderNode(node: Root | RootContent, key: number, images: Record<string
|
||||
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>;
|
||||
}
|
||||
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>;
|
||||
// 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 'html': return <span key={key}>{node.value}</span>;
|
||||
default: return <React.Fragment key={key}>{children}</React.Fragment>;
|
||||
}
|
||||
}
|
||||
|
||||
export function PluginReadme({ markdown, images = {} }: { markdown: string; images?: Record<string, string> }) {
|
||||
export function PluginReadme({ markdown }: { markdown: string }) {
|
||||
const tree = useMemo(() => fromMarkdown(markdown.slice(0, 256 * 1024)), [markdown]);
|
||||
return <div className="break-words text-sm">{renderNode(tree, 0, images)}</div>;
|
||||
return <div className="break-words text-sm">{renderNode(tree, 0)}</div>;
|
||||
}
|
||||
@@ -12,23 +12,21 @@ 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);
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
|
||||
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); };
|
||||
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)[] = [];
|
||||
@@ -40,18 +38,12 @@ 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));
|
||||
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 }] }] };
|
||||
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 }] }] };
|
||||
},
|
||||
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));
|
||||
@@ -59,107 +51,99 @@ 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, perform, viewRequests,
|
||||
rejectSave: (value: boolean) => { rejectSave = value; },
|
||||
section: () => document.querySelector<HTMLElement>('[data-plugin-settings-form]'),
|
||||
gear, scrolled, perform, flushFrames,
|
||||
section: () => document.querySelector<HTMLElement>('section[aria-labelledby="plugin-settings-com.example.alpha"]'),
|
||||
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 }))); },
|
||||
disable: async () => { installed = { ...installed, enabled: false }; await perform(() => { for (const callback of [...listeners]) callback(); }); },
|
||||
replaceOwner: async () => { installed = { ...plugin, source: { ...plugin.source, id: 'third-party:replacement', kind: 'third-party' } }; await perform(() => window.dispatchEvent(new Event('netcatty:plugin-packages-changed'))); },
|
||||
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 opens a dedicated settings modal without management chrome and restores focus', async () => {
|
||||
test('gear entry scrolls and focuses its settings once, after dialog autofocus', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.open();
|
||||
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);
|
||||
assert.notEqual(document.activeElement, f.section());
|
||||
await f.flushFrames();
|
||||
assert.deepEqual(f.scrolled, [f.section()]);
|
||||
assert.equal(document.activeElement, f.section());
|
||||
const input = document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
|
||||
input.focus(); await f.refresh();
|
||||
assert.equal(document.activeElement, input, 'refresh cannot steal focus');
|
||||
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);
|
||||
await f.close();
|
||||
assert.equal(document.activeElement, f.gear);
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
test('gear entry waits for slow contributions before targeting the existing settings section', 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());
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
test('closing between animation frames cancels an already scheduled gear focus', 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);
|
||||
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('settings modal saves across reopening, reports failures and resets', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
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('a slow settings response renders in the modal and never reopens a dismissed modal', async () => {
|
||||
for (const cancellation of ['tab', 'plugin', 'owner', 'close']) test(`pending gear entry cancels on ${cancellation} change`, async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.open(true);
|
||||
assert.ok(document.querySelector('[role=dialog] [role=status]'));
|
||||
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();
|
||||
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);
|
||||
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);
|
||||
}
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
test('removing settings contributions closes the open modal', async () => {
|
||||
test('normal Browse and detail entry retain their existing detail focus', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
await f.open(); await f.disable();
|
||||
assert.equal(document.querySelector('[data-plugin-settings-dialog]'), null);
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
|
||||
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());
|
||||
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.flushFrames();
|
||||
assert.ok(f.section()); assert.equal(f.scrolled.length, 0);
|
||||
assert.equal(document.activeElement, document.querySelector('[data-plugin-detail]'));
|
||||
await f.close(); assert.equal(document.activeElement, browse);
|
||||
} finally { await f.dispose(); }
|
||||
});
|
||||
@@ -42,23 +42,9 @@ 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');
|
||||
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.match(document.body.textContent!, /Running/);
|
||||
assert.match(document.body.textContent!, /Source: Official/);
|
||||
assert.match(document.body.textContent!, /Synthetic source/);
|
||||
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());
|
||||
@@ -77,15 +63,6 @@ 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, Settings } from 'lucide-react';
|
||||
import { RefreshCw, Settings2 } from 'lucide-react';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
import type { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
|
||||
import type { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
@@ -31,9 +31,15 @@ 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 (['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;
|
||||
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}` : ''}`;
|
||||
};
|
||||
return <>
|
||||
<SettingsSection title={t('settings.plugins.landing.options')}>
|
||||
@@ -61,22 +67,19 @@ 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);
|
||||
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 />
|
||||
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>
|
||||
{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>}>
|
||||
<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>}
|
||||
</>}>
|
||||
<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>}
|
||||
<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 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' });
|
||||
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' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
@@ -22,6 +22,7 @@ test('real Settings logging and Ask dialogs open, nest and dismiss correctly', a
|
||||
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>();
|
||||
@@ -38,6 +39,9 @@ test('real Settings logging and Ask dialogs open, nest and dismiss correctly', a
|
||||
} });
|
||||
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));
|
||||
@@ -46,19 +50,24 @@ test('real Settings logging and Ask dialogs open, nest and dismiss correctly', a
|
||||
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(document.querySelectorAll('[role="dialog"]').length, 1);
|
||||
assert.equal(visible, false, 'cancelling verbose returns to the protected logging dialog');
|
||||
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',
|
||||
@@ -66,12 +75,14 @@ test('real Settings logging and Ask dialogs open, nest and dismiss correctly', a
|
||||
});
|
||||
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(document.querySelector('[role="dialog"]'), null);
|
||||
assert.equal(visible, true, 'dismissing Ask must restore the same child');
|
||||
assert.deepEqual(transitions, [false, true, false, true]);
|
||||
} finally {
|
||||
await act(async () => root.unmount()); host.shutdown(); dom.window.close();
|
||||
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -48,21 +48,18 @@ 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-settings-dialog]'));
|
||||
assert.ok(document.querySelector('[data-plugin-detail]'));
|
||||
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('[data-plugin-detail] button[aria-label="Settings for Alpha"]'), 'details offer the same settings entry');
|
||||
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'), 'choosing the plugin again restores its real settings');
|
||||
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, browse);
|
||||
assert.equal(document.activeElement, settings);
|
||||
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 '../../plugins/PluginSettingsForm';
|
||||
import { PluginSettingField } from './SettingsPluginsTab';
|
||||
|
||||
test('plugin setting cards retain localized descriptions alongside structured controls', () => {
|
||||
const setting = {
|
||||
|
||||
@@ -7,25 +7,342 @@ import { PluginPackageManager } from './PluginPackageManager';
|
||||
import { FolderOpen } from 'lucide-react';
|
||||
import { PluginStatus } from './PluginStatus';
|
||||
import { PluginSettingsLanding } from './PluginSettingsLanding';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import React, { useEffect, useLayoutEffect, 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 { PluginSettingsEntry, PluginSettingsDialog } from '../../plugins/PluginSettingsEntry';
|
||||
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';
|
||||
|
||||
function PluginManagementContent({ manager }: {
|
||||
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;
|
||||
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>(null);
|
||||
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(initialSelectedId);
|
||||
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) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
|
||||
<Tabs value={pluginView} onValueChange={(value) => { gearEntry.current = null; 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>
|
||||
@@ -41,7 +358,65 @@ function PluginManagementContent({ manager }: {
|
||||
<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) => { setSelectedInstalled(id); }} detailActions={selectedInstalled && <PluginSettingsEntry key={`settings:${selectedInstalled}`} pluginId={selectedInstalled} />} />
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -50,29 +425,27 @@ function PluginManagementContent({ manager }: {
|
||||
|
||||
|
||||
export default function SettingsPluginsTab() {
|
||||
const { locale } = useI18n();
|
||||
const manager = usePluginManager();
|
||||
const updates = usePluginUpdateCheck(manager.plugins);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [settingsPluginId, setSettingsPluginId] = useState<string | null>(null);
|
||||
const [initialSelectedId, setInitialSelectedId] = useState<string | null>(null);
|
||||
const opener = useRef<HTMLElement | null>(null);
|
||||
const managerButton = useRef<HTMLButtonElement | null>(null);
|
||||
const contributions = usePluginContributions({ locale, context: { 'netcatty.surface': 'settings' } });
|
||||
const contributions = usePluginContributions({ context: { 'netcatty.surface': 'settings' } });
|
||||
const settingsAvailable = useMemo(() => new Set(contributions.snapshot.plugins
|
||||
.filter((plugin) => plugin.settings.some((setting) => setting.visible))
|
||||
.filter((plugin) => plugin.settings.some((setting) => setting.visible) || plugin.views.some((view) => view.location === 'settings' && view.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;
|
||||
if (pluginId) setSettingsPluginId(pluginId);
|
||||
else setOpen(true);
|
||||
setInitialSelectedId(pluginId);
|
||||
setOpen(true);
|
||||
}} />
|
||||
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={managerButton}>
|
||||
<PluginManagementContent manager={manager} />
|
||||
{(handoff) => <PluginManagementContent handoff={handoff} manager={manager} initialSelectedId={initialSelectedId} active={open} />}
|
||||
</PluginManagementDialog>
|
||||
{settingsPluginId && <PluginSettingsDialog key={settingsPluginId} pluginId={settingsPluginId} onClose={() => setSettingsPluginId(null)} restoreFocusTo={opener} />}
|
||||
</SettingsAnchor>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
|
||||
@@ -47,8 +47,6 @@ interface TempDirInfo {
|
||||
path: string;
|
||||
fileCount: number;
|
||||
totalSize: number;
|
||||
isCustom: boolean;
|
||||
defaultPath: string;
|
||||
}
|
||||
|
||||
interface SshDebugLogInfo {
|
||||
@@ -198,9 +196,6 @@ 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);
|
||||
@@ -240,8 +235,6 @@ 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 {
|
||||
@@ -370,37 +363,6 @@ 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;
|
||||
@@ -963,18 +925,6 @@ 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,8 +1,14 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { formatMonitorRate as rate } from "../../domain/monitorFormatting";
|
||||
|
||||
/** Shared rate formatting for aggregate summaries and per-mount readings. */
|
||||
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. */
|
||||
export function DiskIoSummary({ stats }: {
|
||||
stats?: { diskReadSpeed?: number | null; diskWriteSpeed?: number | null } | null;
|
||||
}) {
|
||||
@@ -10,8 +16,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 className="whitespace-nowrap">{t("monitor.diskRead")} {rate(stats?.diskReadSpeed)}</span>
|
||||
<span className="whitespace-nowrap">{t("monitor.diskWrite")} {rate(stats?.diskWriteSpeed)}</span>
|
||||
<span>{t("monitor.diskRead")} {rate(stats?.diskReadSpeed)}</span>
|
||||
<span>{t("monitor.diskWrite")} {rate(stats?.diskWriteSpeed)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,6 @@ interface ResourceBarProps {
|
||||
className?: string;
|
||||
/** Slightly taller track for GPU cards. */
|
||||
size?: 'sm' | 'md';
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
function barTone(clamped: number): string {
|
||||
@@ -21,7 +20,6 @@ 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;
|
||||
@@ -39,12 +37,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 ? (danger ? 'bg-destructive/80' : barTone(clamped)) : 'opacity-0',
|
||||
finite ? barTone(clamped) : 'opacity-0',
|
||||
)}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn('text-[10px] tabular-nums w-10 text-right shrink-0', danger ? 'text-destructive font-medium' : 'text-muted-foreground')}>
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground w-10 text-right shrink-0">
|
||||
{finite ? `${value.toFixed(1)}%` : '--'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1159,7 +1159,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
)}
|
||||
</ToolbarCustomizeContextMenu>
|
||||
|
||||
<PluginTerminalSidebarLauncher sessionId={sessionId} buttonClassName={buttonBase} />
|
||||
{!compactToolbar && <PluginTerminalSidebarLauncher sessionId={sessionId} />}
|
||||
{pluginToolbarMenus.map((menu) => (
|
||||
<Tooltip key={menu.id}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
reorderTerminalSidePanelTab,
|
||||
fitTerminalSidePanelTabs,
|
||||
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
|
||||
TERMINAL_SIDE_PANEL_TAB_IDS,
|
||||
type TerminalSidePanelTabId,
|
||||
useTerminalSidePanelTabOrder,
|
||||
} from '../../application/state/terminalSidePanelTabs';
|
||||
@@ -820,11 +821,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
move: moveSidePanelTab,
|
||||
resetLayout: resetSidePanelTabLayout,
|
||||
partition: partitionSidePanelTabs,
|
||||
} = 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]);
|
||||
} = useTerminalSidePanelTabOrder();
|
||||
const resolvedSidePanelTerminalTheme = useMemo(() => (
|
||||
followAppTerminalTheme
|
||||
? terminalTheme
|
||||
@@ -1022,7 +1019,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
const draggedTab = draggedSidePanelTabRef.current ?? transferredTab;
|
||||
draggedSidePanelTabRef.current = null;
|
||||
setDragOverSidePanelTab(null);
|
||||
if (!availableSidePanelTabSet.has(draggedTab)) return;
|
||||
if (!TERMINAL_SIDE_PANEL_TAB_IDS.has(draggedTab)) return;
|
||||
|
||||
const nextOrder = reorderTerminalSidePanelTab(
|
||||
sidePanelTabOrder,
|
||||
@@ -1033,7 +1030,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
if (nextOrder !== sidePanelTabOrder) {
|
||||
setSidePanelTabOrder(nextOrder);
|
||||
}
|
||||
}, [availableSidePanelTabSet, dragOverSidePanelTab, setSidePanelTabOrder, sidePanelTabOrder]);
|
||||
}, [dragOverSidePanelTab, setSidePanelTabOrder, sidePanelTabOrder]);
|
||||
|
||||
const handleSidePanelTabDragOver = useCallback((event: React.DragEvent, targetTab: TerminalSidePanelTabId) => {
|
||||
if (!Array.from(event.dataTransfer.types).includes(SIDE_PANEL_TAB_DRAG_MIME)) return;
|
||||
@@ -1166,12 +1163,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
}, [focusedPaneHost, handleSplitSidePanelPane]);
|
||||
|
||||
const { shown: configuredShownSidePanelTabs, collapsed: configuredCollapsedSidePanelTabs } = useMemo(() => {
|
||||
const partition = partitionSidePanelTabs(availableSidePanelTabs);
|
||||
const parts = {
|
||||
shown: partition.shown as SidePanelTab[],
|
||||
collapsed: partition.collapsed as SidePanelTab[],
|
||||
hidden: partition.hidden as SidePanelTab[],
|
||||
};
|
||||
const parts = partitionSidePanelTabs(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER);
|
||||
// If an external path opens a hidden tab, still show its chip while active.
|
||||
if (
|
||||
activeSidePanelTab &&
|
||||
@@ -1185,7 +1177,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
};
|
||||
}
|
||||
return parts;
|
||||
}, [activeSidePanelTab, availableSidePanelTabs, partitionSidePanelTabs]);
|
||||
}, [activeSidePanelTab, 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;
|
||||
@@ -1206,7 +1198,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
|
||||
const sidePanelCustomizeItems = useMemo(
|
||||
() =>
|
||||
sidePanelTabOrder.filter((tabId) => availableSidePanelTabSet.has(tabId)).map((tabId) => {
|
||||
sidePanelTabOrder.map((tabId) => {
|
||||
const item = sidePanelTabItemById.get(tabId);
|
||||
return {
|
||||
id: tabId,
|
||||
@@ -1214,7 +1206,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
icon: item?.icon,
|
||||
};
|
||||
}),
|
||||
[availableSidePanelTabSet, sidePanelTabItemById, sidePanelTabOrder],
|
||||
[sidePanelTabItemById, sidePanelTabOrder],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -1296,7 +1288,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
const next = setSidePanelTabPlacement(
|
||||
id,
|
||||
placement,
|
||||
availableSidePanelTabs,
|
||||
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
|
||||
);
|
||||
// Only close when hide actually stuck (not reverted by requireReachable).
|
||||
if (activeSidePanelTab === id && (next.placement[id] ?? 'show') === 'hide') {
|
||||
@@ -1304,7 +1296,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
|
||||
}
|
||||
}}
|
||||
onMove={(id, direction) =>
|
||||
moveSidePanelTab(id, direction, availableSidePanelTabs)
|
||||
moveSidePanelTab(id, direction, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER)
|
||||
}
|
||||
onReset={resetSidePanelTabLayout}
|
||||
t={t}
|
||||
@@ -1525,8 +1517,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}
|
||||
showHeader={activePaneCount === 1}
|
||||
getSession={ctx.getActiveTerminalSessionId}
|
||||
focusOnReveal={activeSidePanelLayout?.focusedPaneId === pane.id}
|
||||
adapter={pluginSidebar.contributions} getSession={ctx.getActiveTerminalSessionId}
|
||||
unavailable={() => ctx.handleWithdrawPluginSidebarPane?.(activeTabId, pane.id, page.tool)}
|
||||
focused={() => { if (activeTabStore.getActiveTabId() === activeTabId) ctx.handleFocusSidePanelPane?.(pane.id); }} />
|
||||
</PersistentSidePanelPortal>;
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
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,9 +1,7 @@
|
||||
import React from "react";
|
||||
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 type { MonitorStats } from "../../domain/monitorRuntime";
|
||||
import { ResourceBar } from "../systemManager/ResourceBar";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
|
||||
|
||||
@@ -13,43 +11,35 @@ const diskCapacity = (value: number) => value >= 1024
|
||||
? `${(value * 1024).toFixed(1)} MiB`
|
||||
: `${value.toFixed(1)} GiB`;
|
||||
|
||||
/** Cards show the largest capacities; the list puts system mounts first. */
|
||||
/** Cards show the largest capacities; the list keeps every reported mount. */
|
||||
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 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 === "/";
|
||||
const disks = limit == null
|
||||
? reported
|
||||
: [...reported].sort((a, b) =>
|
||||
("total" in b ? b.total : 0) - ("total" in a ? a.total : 0),
|
||||
).slice(0, limit);
|
||||
return (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<div className={limit === 2 ? "min-h-[38px] space-y-1.5" : "space-y-1.5"}>
|
||||
{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 (
|
||||
<DiskIoSummary stats={stats} />
|
||||
{disks.map((disk, index) => (
|
||||
<Tooltip key={`${disk.mountPoint}-${index}`}>
|
||||
<TooltipTrigger asChild>
|
||||
<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"}`}>
|
||||
<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">
|
||||
{disk.mountPoint}
|
||||
</span>}
|
||||
<ResourceBar label="" value={disk.percent} className={bareRoot ? "h-4 w-full" : "flex-1"} danger={alert} />
|
||||
</span>
|
||||
<ResourceBar label="" value={disk.percent} className="flex-1" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-80 bg-popover text-popover-foreground shadow-md ring-1 ring-border/40">
|
||||
@@ -61,8 +51,7 @@ export function MonitorDiskUsage({
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function MonitorHostCollection<T extends { id: string }>({
|
||||
layoutKey: string;
|
||||
pinnedId?: string;
|
||||
renderCard: (item: T) => React.ReactNode;
|
||||
renderCells: (item: T, visibleColumns: readonly MonitorColumn[]) => Record<MonitorColumn, React.ReactNode>;
|
||||
renderCells: (item: T) => Record<MonitorColumn, React.ReactNode>;
|
||||
rowProps: (item: T) => React.HTMLAttributes<HTMLTableRowElement>;
|
||||
renderMenu: (item: T) => React.ReactNode;
|
||||
}) {
|
||||
@@ -232,7 +232,7 @@ export function MonitorHostCollection<T extends { id: string }>({
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const cells = renderCells(items[row.index], visibleColumns.map(column => column.key));
|
||||
const cells = renderCells(items[row.index]);
|
||||
return (
|
||||
<ContextMenu key={row.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { Host } from "../../domain/models";
|
||||
import type { MonitorPoint } from "../../domain/monitorRuntime";
|
||||
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
|
||||
import { MonitorDiskUsage } from "./MonitorDiskUsage";
|
||||
import { MonitorDiskIo } from "./MonitorDiskIo";
|
||||
import { MonitorHostLatency } from "./MonitorHostMetadata";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
|
||||
@@ -133,7 +132,7 @@ test("disk usage shows every reported partition, including healthy and empty dis
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<MonitorDiskUsage stats={{ disks, diskPercent: 25 }} />,
|
||||
)).window.document;
|
||||
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk-label]")].map((e) => e.textContent), ["/", "/backup", "/data"]);
|
||||
assert.deepEqual([...document.querySelectorAll("[data-monitor-disk] > span")].map((e) => e.textContent), ["/", "/data", "/backup"]);
|
||||
for (const value of ["25.0%", "95.0%", "0.0%"])
|
||||
assert.ok(document.body.textContent!.includes(value));
|
||||
});
|
||||
@@ -159,15 +158,6 @@ test("cards show the two largest capacities without changing the source disk ord
|
||||
assert.deepEqual(disks.map((disk) => disk.mountPoint), ["/small", "/data", "/backup"]);
|
||||
});
|
||||
|
||||
test("cards retain aggregate disk I/O while lists keep it in the separate column", () => {
|
||||
const stats = { disks: [], diskPercent: 10, diskReadSpeed: 1000, diskWriteSpeed: 2e6 };
|
||||
const markup = (limit?: number) => new JSDOM(renderToStaticMarkup(
|
||||
<I18nProvider locale="en"><MonitorDiskUsage stats={stats} limit={limit} /></I18nProvider>,
|
||||
)).window.document;
|
||||
assert.match(markup(2).querySelector("[data-disk-io]")!.textContent!, /Read 1.0 KB\/s.*Write 2.0 MB\/s/);
|
||||
assert.equal(markup().querySelector("[data-disk-io]"), null);
|
||||
});
|
||||
|
||||
for (const view of ["list", "charts"] as const) {
|
||||
test(`${view} keeps saved custom icons, colors and OS logos`, (t) => {
|
||||
const storage = new Map<string, string>();
|
||||
@@ -191,9 +181,6 @@ for (const view of ["list", "charts"] as const) {
|
||||
assert.equal(document.querySelector("header .lucide-gauge"), null);
|
||||
assert.equal(document.querySelector(".lucide-activity"), null);
|
||||
if (view === "list") {
|
||||
const diskIo = document.querySelector("[data-monitor-cell=diskIo]")!;
|
||||
assert.ok(diskIo.querySelector("[data-monitor-disk-io-total]"));
|
||||
assert.equal(document.querySelector("[data-monitor-column=diskIo]")?.textContent, "Disk I/O");
|
||||
const network = document.querySelector("[data-monitor-cell=network]")!;
|
||||
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
|
||||
}
|
||||
@@ -204,39 +191,14 @@ test("disk I/O distinguishes warming up or unsupported from idle and reports bot
|
||||
for (const [rates, expected] of [
|
||||
[{}, ["Read --", "Write --"]],
|
||||
[{ diskReadSpeed: null, diskWriteSpeed: null }, ["Read --", "Write --"]],
|
||||
[{ diskReadSpeed: 0, diskWriteSpeed: 0 }, ["Read 0.0 KB/s", "Write 0.0 KB/s"]],
|
||||
[{ diskReadSpeed: 1000, diskWriteSpeed: 2000000 }, ["Read 1.0 KB/s", "Write 2.0 MB/s"]],
|
||||
[{ diskReadSpeed: 0, diskWriteSpeed: 0 }, ["Read 0 B/s", "Write 0 B/s"]],
|
||||
[{ diskReadSpeed: 1024, diskWriteSpeed: 2097152 }, ["Read 1.0 KiB/s", "Write 2.0 MiB/s"]],
|
||||
] as const) {
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<I18nProvider locale="en"><MonitorDiskIo stats={{ disks: [{ mountPoint: "/", used: 1, total: 10, percent: 10, ...rates }] }} /></I18nProvider>,
|
||||
<I18nProvider locale="en"><MonitorDiskUsage stats={{ disks: [], diskPercent: null, ...rates }} /></I18nProvider>,
|
||||
)).window.document;
|
||||
const io = document.querySelector("[data-monitor-disk-io]")!;
|
||||
const io = document.querySelector("[data-disk-io]")!;
|
||||
for (const text of expected) assert.ok(io.textContent?.includes(text));
|
||||
assert.match(io.getAttribute("title")!, /block device.*not supported on macOS/);
|
||||
assert.match(io.getAttribute("title")!, /Linux.*not supported on macOS/);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("disk capacity and I/O columns share system-first order and isolate the physical aggregate", () => {
|
||||
const stats = {
|
||||
diskPercent: 10, diskReadSpeed: 5000000, diskWriteSpeed: 2000000000,
|
||||
disks: [
|
||||
{ mountPoint: "/", used: 1, total: 10, percent: 10, diskReadSpeed: 1000, diskWriteSpeed: 0 },
|
||||
{ mountPoint: "/bd10", used: 2, total: 20, percent: 10, diskReadSpeed: 2000000, diskWriteSpeed: 4000 },
|
||||
{ mountPoint: "/bd02", used: 1, total: 10, percent: 10, diskReadSpeed: 1000, diskWriteSpeed: 0 },
|
||||
],
|
||||
};
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<I18nProvider locale="en"><div data-capacity><MonitorDiskUsage stats={stats} /></div><MonitorDiskIo stats={stats} /></I18nProvider>,
|
||||
)).window.document;
|
||||
const capacity = document.querySelector("[data-capacity]")!;
|
||||
const rows = [...document.querySelectorAll("[data-monitor-disk-io]")];
|
||||
assert.equal(capacity.querySelectorAll("[data-disk-io], [data-monitor-disk-io]").length, 0);
|
||||
assert.deepEqual(rows.map(row => row.getAttribute("data-monitor-disk-io")),
|
||||
[...capacity.querySelectorAll("[data-monitor-disk]")].map(row => row.getAttribute("data-monitor-disk")));
|
||||
assert.match(rows[0].textContent!, /Read 1.0 KB\/s.*Write 0.0 KB\/s/);
|
||||
assert.deepEqual(rows.map(row => row.getAttribute("data-monitor-disk-io")), ["/", "/bd02", "/bd10"]);
|
||||
assert.match(rows[2].textContent!, /Read 2.0 MB\/s.*Write 4.0 KB\/s/);
|
||||
const total = document.querySelector("[data-monitor-disk-io-total]")!;
|
||||
assert.match(total.textContent!, /Total.*Read 5.0 MB\/s.*Write 2.0 GB\/s/);
|
||||
});
|
||||
@@ -70,9 +70,6 @@ import SelectHostPanel from "../SelectHostPanel";
|
||||
import { DistroAvatar } from "../DistroAvatar";
|
||||
import { ResourceBar } from "../systemManager/ResourceBar";
|
||||
import { MonitorDiskUsage } from "./MonitorDiskUsage";
|
||||
import { MonitorDiskIo } from "./MonitorDiskIo";
|
||||
import { useMonitorDiskGroups } from "../../application/state/useMonitorDiskGroups";
|
||||
import { formatMonitorRate as rate } from "../../domain/monitorFormatting";
|
||||
import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass } from "./VaultPageHeader";
|
||||
import { toast } from "../ui/toast";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
@@ -88,6 +85,12 @@ const capacity = (n: number | null | undefined) =>
|
||||
: n >= 1024
|
||||
? `${(n / 1024).toFixed(1)} GiB`
|
||||
: `${n.toFixed(0)} MiB`;
|
||||
const rate = (n: number | null | undefined) =>
|
||||
n == null
|
||||
? "--"
|
||||
: n >= 1048576
|
||||
? `${(n / 1048576).toFixed(1)} MiB/s`
|
||||
: `${(n / 1024).toFixed(1)} KiB/s`;
|
||||
|
||||
const monitorAsideHeaderClass =
|
||||
"relative h-14 py-0 border-b-0 after:pointer-events-none after:absolute after:inset-x-0 after:bottom-0 after:h-px after:origin-bottom after:[transform:scaleY(.5)] after:bg-border/40 after:content-['']";
|
||||
@@ -206,7 +209,6 @@ export function MonitorPanels({
|
||||
const { t } = useI18n();
|
||||
const panels = useMonitorPanels();
|
||||
const runtime = useMonitorSnapshot();
|
||||
const diskGroups = useMonitorDiskGroups();
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [dragging, setDragging] = useState<{ panelId: string; hostId: string } | null>(null);
|
||||
@@ -833,9 +835,7 @@ export function MonitorPanels({
|
||||
</Button>
|
||||
</article>
|
||||
)}
|
||||
renderCells={({ id, host, state }, visibleColumns) => {
|
||||
const diskLayout = diskGroups.layoutFor(id, state?.stats);
|
||||
return ({
|
||||
renderCells={({ id, host, state }) => ({
|
||||
host: (<td>
|
||||
<button
|
||||
type="button"
|
||||
@@ -900,16 +900,11 @@ export function MonitorPanels({
|
||||
}
|
||||
/>
|
||||
</td>),
|
||||
disks: (<td className={diskLayout.rows.length === 1 && diskLayout.rows[0].kind === "disk" && diskLayout.rows[0].disk.mountPoint === "/" ? undefined : "!items-start"}>
|
||||
disks: (<td>
|
||||
<div className="w-full min-w-0">
|
||||
<MonitorDiskUsage stats={state?.stats} listLayout={diskLayout} onToggleSystem={() => diskGroups.toggle(id)} />
|
||||
<MonitorDiskUsage stats={state?.stats} />
|
||||
</div>
|
||||
</td>),
|
||||
diskIo: (<td data-monitor-cell="diskIo" className="!items-start">
|
||||
<MonitorDiskIo stats={state?.stats} listLayout={diskLayout}
|
||||
showSystemToggle={!visibleColumns.includes("disks")}
|
||||
onToggleSystem={() => diskGroups.toggle(id)} />
|
||||
</td>),
|
||||
network: (<td
|
||||
data-monitor-cell="network"
|
||||
className="tabular-nums whitespace-nowrap"
|
||||
@@ -969,8 +964,7 @@ export function MonitorPanels({
|
||||
<Terminal size={14} />
|
||||
</Button>
|
||||
</td>)
|
||||
});
|
||||
}}
|
||||
})}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from "react";
|
||||
import { ChevronRight, TriangleAlert } from "lucide-react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import type { MonitorDiskListRow } from "../../domain/monitorDisks";
|
||||
|
||||
export function MonitorSystemDiskToggle({ row, onToggle }: {
|
||||
row: Extract<MonitorDiskListRow, { kind: "system" }>;
|
||||
onToggle?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<button type="button" data-monitor-system-disks aria-expanded={row.expanded}
|
||||
disabled={row.alert || !onToggle}
|
||||
title={row.alert ? t("monitor.systemDisksAlert") : undefined}
|
||||
onClick={event => { event.stopPropagation(); onToggle?.(); }}
|
||||
className={`flex h-4 w-full items-center gap-1 rounded text-left text-[10px] outline-none focus-visible:ring-1 focus-visible:ring-ring ${row.alert ? "font-medium text-destructive" : "text-muted-foreground hover:text-foreground"}`}>
|
||||
<ChevronRight size={11} aria-hidden="true" className={row.expanded ? "rotate-90" : ""} />
|
||||
{t("monitor.systemDisks", { count: row.count })}
|
||||
{row.alert && <TriangleAlert size={11} aria-hidden="true" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -176,34 +176,3 @@ test("grouped host grid uses one virtual window across every group", () => {
|
||||
assert.ok(renderedHosts < 100);
|
||||
assert.equal((html.match(/data-vault-virtual-grouped-collection=/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test("pointer focus does not preselect hosts in either collection", async () => {
|
||||
const { act, create } = await import("react-test-renderer");
|
||||
const items = [{ id: "one" }, { id: "two" }];
|
||||
for (const grouped of [false, true]) {
|
||||
const selected: string[] = [];
|
||||
const common = {
|
||||
itemKey: (item: { id: string }) => item.id,
|
||||
scrollRef: React.createRef<HTMLDivElement>(),
|
||||
viewMode: "list" as const,
|
||||
onActiveItemChange: (item: { id: string }) => selected.push(item.id),
|
||||
renderItem: (item: { id: string }) => <div>{item.id}</div>,
|
||||
};
|
||||
let renderer: ReturnType<typeof create>;
|
||||
await act(() => {
|
||||
renderer = create(grouped
|
||||
? <VirtualizedGroupedHostCollection {...common} groups={[{ name: "group", hosts: items }]} renderGroupHeader={() => null} />
|
||||
: <VirtualizedHostCollection {...common} items={items} />);
|
||||
});
|
||||
const root = renderer!.root.find((node) => Boolean(node.props.onFocusCapture));
|
||||
const target = { closest: () => ({ dataset: { vaultItemKey: "one" } }) };
|
||||
await act(() => {
|
||||
root.props.onPointerDownCapture();
|
||||
root.props.onFocusCapture({ target });
|
||||
});
|
||||
assert.deepEqual(selected, []);
|
||||
await act(() => { root.props.onFocusCapture({ target }); });
|
||||
assert.deepEqual(selected, ["one"]);
|
||||
await act(() => { renderer!.unmount(); });
|
||||
}
|
||||
});
|
||||
@@ -127,7 +127,6 @@ export function VirtualizedHostCollection<T>({
|
||||
onDrop?: React.DragEventHandler<HTMLDivElement>;
|
||||
}) {
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const pointerFocusRef = React.useRef(false);
|
||||
const pendingFocusKeyRef = React.useRef<string | null>(null);
|
||||
const lastRequestedActiveKeyRef = React.useRef<string | null>(null);
|
||||
const [containerWidth, setContainerWidth] = React.useState(
|
||||
@@ -280,18 +279,8 @@ export function VirtualizedHostCollection<T>({
|
||||
aria-label={ariaLabel}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onPointerDownCapture={() => { pointerFocusRef.current = true; }}
|
||||
onPointerUpCapture={() => { pointerFocusRef.current = false; }}
|
||||
onPointerCancelCapture={() => { pointerFocusRef.current = false; }}
|
||||
onKeyDownCapture={(event) => {
|
||||
pointerFocusRef.current = false;
|
||||
handleKeyDown(event);
|
||||
}}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
onFocusCapture={(event) => {
|
||||
if (pointerFocusRef.current) {
|
||||
pointerFocusRef.current = false;
|
||||
return;
|
||||
}
|
||||
const wrapper = (event.target as HTMLElement).closest<HTMLElement>("[data-vault-item-key]");
|
||||
const index = wrapper
|
||||
? itemIndexByKey.get(wrapper.dataset.vaultItemKey ?? "")
|
||||
@@ -376,7 +365,6 @@ export function VirtualizedGroupedHostCollection<T>({
|
||||
onBoundaryNavigation?: (direction: "previous" | "next") => void;
|
||||
}) {
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
const pointerFocusRef = React.useRef(false);
|
||||
const pendingFocusKeyRef = React.useRef<string | null>(null);
|
||||
const lastRequestedActiveKeyRef = React.useRef<string | null>(null);
|
||||
const [containerWidth, setContainerWidth] = React.useState(
|
||||
@@ -596,18 +584,8 @@ export function VirtualizedGroupedHostCollection<T>({
|
||||
aria-rowcount={viewMode === "grid" ? rows.length : undefined}
|
||||
aria-colcount={viewMode === "grid" ? columns : undefined}
|
||||
aria-label={ariaLabel}
|
||||
onPointerDownCapture={() => { pointerFocusRef.current = true; }}
|
||||
onPointerUpCapture={() => { pointerFocusRef.current = false; }}
|
||||
onPointerCancelCapture={() => { pointerFocusRef.current = false; }}
|
||||
onKeyDownCapture={(event) => {
|
||||
pointerFocusRef.current = false;
|
||||
handleKeyDown(event);
|
||||
}}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
onFocusCapture={(event) => {
|
||||
if (pointerFocusRef.current) {
|
||||
pointerFocusRef.current = false;
|
||||
return;
|
||||
}
|
||||
const wrapper = (event.target as HTMLElement).closest<HTMLElement>("[data-vault-item-key]");
|
||||
const index = wrapper
|
||||
? itemIndexByKey.get(wrapper.dataset.vaultItemKey ?? "")
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Embedded plugin UI feasibility
|
||||
|
||||
> Research only. The user subsequently clarified that plugin UI must be drawn
|
||||
> by Netcatty, not displayed in an embedded frame. See
|
||||
> [host-rendered React UI research](./native-react-ui-research.md).
|
||||
|
||||
## Goal
|
||||
|
||||
Allow ordinary React, HTML and CSS inside the application's normal stacking
|
||||
order while keeping the existing plugin permissions and offline-by-default
|
||||
execution boundary. The plugin must not gain access to the host document,
|
||||
privileged preload, other plugins, files, the network, or navigation simply
|
||||
because it can render a view.
|
||||
|
||||
## Evidence from the current runtime
|
||||
|
||||
`electron/plugins/embeddedFrameSecurity.live.test.cjs` is an isolated Electron
|
||||
proof, not a product implementation. Run it with:
|
||||
|
||||
```
|
||||
./node_modules/.bin/electron electron/plugins/embeddedFrameSecurity.live.test.cjs
|
||||
```
|
||||
|
||||
On Electron 42.11.12 (Chromium 148.0.7778.280), a frame with `sandbox=allow-scripts`,
|
||||
a restrictive CSP and no Node integration could not read its parent or local
|
||||
storage. A host overlay appeared above it. Fetch and image requests were blocked,
|
||||
and `will-frame-navigate` stopped an attempted navigation. The same frame could
|
||||
still send UDP packets to a local STUN endpoint with `RTCPeerConnection`: in
|
||||
one run three packets arrived. This proves that the prototype's CSP and frame
|
||||
sandbox do not reproduce the existing offline-session boundary.
|
||||
|
||||
`Connection-Allowlist: (); webrtc=block` did not prevent those packets on this
|
||||
Chromium build, even when the experimental `ConnectionAllowlists` switch was
|
||||
supplied. Chrome's official announcement says the header is introduced in Chrome
|
||||
152; it must not be assumed to protect the current Electron version. A future
|
||||
upgrade still requires adversarial verification for every supported platform.
|
||||
|
||||
- https://developer.chrome.com/blog/connection-allowlist-announcement
|
||||
- https://www.electronjs.org/docs/latest/tutorial/web-embeds
|
||||
- https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe
|
||||
|
||||
## Required work before adoption
|
||||
|
||||
1. Enforce and independently test per-plugin network isolation, including
|
||||
WebRTC, WebTransport, navigation, redirects, downloads, workers, DNS and
|
||||
browser APIs. A JavaScript shim or CSP alone is not a security boundary.
|
||||
2. Register packaged assets in the actual owner session without exposing host
|
||||
assets or another plugin's package. Test opaque-origin module loading, CSS,
|
||||
local images and fonts, and reject navigation away from the registered view.
|
||||
3. Ensure no child frame receives privileged preloads or can call trusted IPC;
|
||||
validate both the actual sender frame and ownership on every operation.
|
||||
4. Use a dedicated bounded message channel tied to a live instance and owner;
|
||||
reject cross-window, stale, replayed and unauthorized requests, and close on
|
||||
lock, disable, unload, crash, navigation and window shutdown.
|
||||
5. Preserve view state, themes, accessibility, focus, shortcuts and layout
|
||||
across Vault, terminal sidebar, tab, modal, panel, aside and settings.
|
||||
6. Add a real React example and verify host menus and dialogs overlap it without
|
||||
hiding the page. Run isolation tests and interaction tests on all supported OSes.
|
||||
|
||||
Do not enable this as a general plugin surface until these conditions pass.
|
||||
The current `WebContentsView` path remains active meanwhile. Alternative designs
|
||||
include a host-rendered protocol with isolated React logic; that can retain the
|
||||
existing offline runtime, but needs a substantial compatibility layer for
|
||||
ordinary React DOM libraries and truly free-form CSS.
|
||||
@@ -1,163 +0,0 @@
|
||||
# Host-rendered plugin UI
|
||||
|
||||
This is the only custom-view rendering model. The platform is unpublished:
|
||||
there is no HTML-entry fallback, native child view, migration, or parallel UI
|
||||
contract. Browser plugins can use it without `NETCATTY_PLUGIN_DEV`. Advanced
|
||||
Node runtimes retain their separate development admission policy.
|
||||
|
||||
## Authoring
|
||||
|
||||
Declare `id`, `title`, and `location` in `contributes.views`, plus the `views`
|
||||
permission and an `onView:<id>` activation event. The six custom-page locations are
|
||||
`vault-page`, `terminal-sidebar`, `tab`, `aside`, `panel`, and `modal`.
|
||||
Settings use host-owned forms described in [plugin-settings.md](plugin-settings.md);
|
||||
`settings` is not a custom view location.
|
||||
The view declaration has no `entry`, `rendering`, or retention flag.
|
||||
|
||||
Use ordinary React and a stylesheet:
|
||||
|
||||
```tsx
|
||||
import { registerHostReactView, PluginPopover } from '@netcatty/plugin-sdk/host-react';
|
||||
|
||||
export default {
|
||||
activate(context) {
|
||||
context.subscriptions.add(registerHostReactView(
|
||||
context.views, 'com.example.plugin.page', () => <MyPage />, css,
|
||||
));
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Bundle the `host-react` subpath, React, and React DOM into the browser entry.
|
||||
The root SDK may be bundled or resolved by the runtime's SDK mapping. For plain
|
||||
JavaScript, `registerHostDomView(views, id, (root, stateScope) => cleanup, css)`
|
||||
accepts ordinary DOM authoring. The root exists in the isolated runtime;
|
||||
Netcatty receives validated data and creates the visible elements itself.
|
||||
Never hand a function, live DOM node, or Electron object to the host.
|
||||
|
||||
There is no required component catalogue. Use Grid/Flex, container queries,
|
||||
custom colors, typography, borders, gradients, transforms and animation.
|
||||
Seven optional semantic HSL variables follow the host theme: `--background`,
|
||||
`--foreground`, `--muted`, `--muted-foreground`, `--border`, `--primary`, and
|
||||
`--primary-foreground`. Author-defined variables use the `--plugin-` prefix.
|
||||
|
||||
Controls support text, textarea, checkbox/radio, select/multiselect, buttons,
|
||||
forms, focus, keys and composition events. Host values stay local while an
|
||||
interaction awaits its ordered acknowledgement, preventing older updates from
|
||||
overwriting newer typing. Only events inside the owning surface are forwarded.
|
||||
Forms cannot navigate or send requests. Clipboard reads are not exposed by this
|
||||
UI interface; a normal paste into the plugin's own input is user-provided data.
|
||||
|
||||
`<img src="asset:images/mark.svg" alt="Mark" />` loads an image only from that
|
||||
installed plugin's verified package. The host checks path containment, size and
|
||||
format, rejects SVG resource references, and converts the image to PNG in an
|
||||
isolated decoder. Raw SVG never enters the host document. Source limit: 512 KiB;
|
||||
output edge: 2048 pixels; output size: 4 MiB. Per instance: 32 resource paths and
|
||||
8 MiB of returned data. Network URLs, arbitrary data URLs and CSS URL loads are
|
||||
rejected. Use installed system fonts; packaged font files and canvas are not
|
||||
part of the current supported surface.
|
||||
|
||||
For a menu that extends beyond a pane, use `PluginPopover` with an anchor element
|
||||
ID, `open`, and `onClose`. Its children are freely authored UI. Netcatty positions
|
||||
and clips it, handles outside click and Escape, restores anchor focus, and keeps
|
||||
it below trusted host dialogs. Only the originating view's anchor can be used.
|
||||
Nested popovers are not supported. Ordinary menus contained inside the surface
|
||||
can be styled without this helper. Libraries that depend on real host DOM
|
||||
measurements, arbitrary portals, browser navigation, canvas or active elements
|
||||
need adaptation; ordinary React support does not mean every browser library works.
|
||||
|
||||
## Lifecycle and navigation
|
||||
|
||||
Enabled contributions remain registered independently of visible panes.
|
||||
Terminal sidebar pages participate in built-in ordering, overflow, customization,
|
||||
focus and splits. Closing the whole sidebar does not delete their entries.
|
||||
Vault and tabs use the same rounded content surface as built-in pages.
|
||||
|
||||
Each window retains at most 16 UI instances. Hiding or switching a page reuses
|
||||
its instance and React state for up to five idle minutes; unused instances may
|
||||
be evicted sooner to admit a new view. Explicit tab/overlay close disposes its
|
||||
instance. Persistent state uses `context.views.getState/setState(viewId,
|
||||
stateScope, ...)`, with the opaque `stateScope` supplied to a DOM mount. This
|
||||
stores only the plugin's own data, not host records or credentials. State scopes
|
||||
separate placements and terminal sessions but are not an authorization boundary
|
||||
against other code in the same plugin.
|
||||
|
||||
The main process binds each instance to its actual owner window and exact
|
||||
plugin/runtime version. Close during activation, owner navigation/crash, plugin
|
||||
disable/replacement/uninstall, runtime failure and App Lock invalidate it.
|
||||
Late updates, image results and events cannot revive a closed instance.
|
||||
Another window cannot send events, load resources or close it. The renderer's
|
||||
cache is not the authority for these checks.
|
||||
|
||||
## Security boundaries
|
||||
|
||||
Plugin JavaScript runs only in the existing offline sandbox runtime, without
|
||||
Node or the host application bridge. Its private session permits its own package
|
||||
and SDK modules; browser networking, WebRTC direct UDP, navigation, downloads,
|
||||
frames and device permissions are blocked. Privileged operations still pass
|
||||
through the existing permission services. The `views` permission grants no
|
||||
host, terminal, file, network, secret or model access.
|
||||
|
||||
Incoming updates pass schema, sequencing, rate, mutation and CSS checks in the
|
||||
main process, then a complete data-tree validation before rendering. Limits:
|
||||
1024 nodes, depth 24, 128 KiB text/attributes, 2048 mutations and 256 KiB per
|
||||
batch, 64 KiB CSS, 120 updates per second. Invalid data closes the view.
|
||||
Scripts, handlers, executable/custom elements, unsafe attributes, URL loads,
|
||||
imports, host selectors and escaping positioning are denied. Closed Shadow DOM
|
||||
separates styling; clipping and stacking contain output. Neither is relied on
|
||||
as a JavaScript isolation boundary.
|
||||
|
||||
No system can promise that every arbitrary plugin is harmless. User-supplied
|
||||
text in a plugin belongs to that plugin, and explicitly authorized capabilities
|
||||
can provide additional data. Isolation and quotas reduce the available attack
|
||||
surface; they do not certify third-party code or all future browser versions.
|
||||
|
||||
## Examples and checks
|
||||
|
||||
`hello-react-host` exercises all six custom-page locations, text and Chinese input,
|
||||
checkbox/select/textarea updates, package images and a custom menu. Build with
|
||||
`npm run build:plugin-view-examples`. Plain DOM examples, including Hello World 2,
|
||||
starter, scratchpads and assistants, use the same host renderer.
|
||||
|
||||
- `npm run test:plugin-contract`: package and public contract.
|
||||
- `npm run test:plugin-host-drawn`: tree/style policy, ownership, state retention,
|
||||
resource checks and real Vault/sidebar navigation components.
|
||||
- `npm run test:plugin-runtime`: runtime, permissions, package and host services.
|
||||
- `NETCATTY_PLUGIN_DEV=1 npm run test:plugin-runtime:electron`: browser and advanced
|
||||
runtime smoke tests, including local HTTP/UDP isolation probes.
|
||||
- Start Vite on port 5182, then run `npm run test:plugin-host-drawn:electron` and
|
||||
`electron electron/plugins/helloWorldV2.live.test.cjs`: installed examples and
|
||||
actual host DOM interactions. These use dedicated disposable profiles.
|
||||
- `npm run pack:dir`: verify runtime dependencies and production resources.
|
||||
|
||||
The custom-page fixture exercises the shared rendering implementation;
|
||||
Vault/sidebar component tests exercise their actual routing and split logic.
|
||||
This is not a claim that every manual navigation flow, third-party component
|
||||
library or malicious plugin has been exhaustively verified.
|
||||
|
||||
## Verification record (2026-10-29)
|
||||
|
||||
- Installed and enabled the locally built `hello-react-host.ncpkg` through the
|
||||
actual development application's Settings UI. Vault showed the shared rounded
|
||||
surface; reset, greeting, image and theme rendering worked.
|
||||
- In a local terminal, opened the contributed sidebar, filled its controls,
|
||||
closed the whole sidebar, then reopened it through the built-in shortcut.
|
||||
The tool entry and previously entered values remained. The custom menu action
|
||||
updated the greeting and closed the menu.
|
||||
- The real Electron rendering fixture passed the then-seven view declarations (settings is now host-owned),
|
||||
controls and Chinese composition, package images, Escape dismissal, retained
|
||||
state after unmount/remount, trusted-dialog dismissal and a host-only input
|
||||
canary that was never forwarded. The DOM-example fixture passed 12 views.
|
||||
- Runtime suite: 752 passed, 6 environment-specific tests skipped; focused
|
||||
host-rendered UI suite: 23 passed. Public-contract suite: 91 passed, generated
|
||||
contract check passed. Additional assistant command/adapter checks and the
|
||||
global-agent layout fixture passed. The real sandbox suite passed its HTTP
|
||||
and UDP probes.
|
||||
- `npm run lint`: zero errors and eight existing warnings. `npm run pack:dir`
|
||||
passed; the produced archive contains the new host, validators and CSS parser,
|
||||
and excludes the removed native plugin view host.
|
||||
- Whole-project `tsc --noEmit` still fails on existing unrelated errors. In the
|
||||
changed production files, the remaining reported error is the unchanged SFTP
|
||||
context assignment in `TerminalLayerSidePanelSection.tsx`; the newly added
|
||||
host-rendering modules have no reported errors. This does not turn the whole
|
||||
project check into a passing check.
|
||||
@@ -1,199 +0,0 @@
|
||||
# Host-rendered React plugin UI: research and direction
|
||||
|
||||
## Goal
|
||||
|
||||
Render plugin UI inside Netcatty's own interface, with React authoring and
|
||||
flexible layout and styling. Plugin code must not execute in the host renderer
|
||||
or obtain host state or user data merely by rendering a view. Only data entered
|
||||
in the plugin's own UI, explicitly granted capabilities, and host-provided
|
||||
non-sensitive environment information may reach a plugin.
|
||||
|
||||
## Design status and continuity
|
||||
|
||||
This is an unpublished feature. There are no released plugin UI contracts to
|
||||
preserve. Design one host-drawn UI model: no legacy view fallback, migration
|
||||
scheme, or dual rendering mode is required. The existing prototype, examples,
|
||||
and test evidence are retained as the starting point. The replacement is now implemented as a single
|
||||
host-drawn path. The attached native view, its preload, geometry/focus bridge,
|
||||
and renderer-selection fields have been removed. Current authoring and limits
|
||||
are documented in [host-rendered UI](./host-rendered-ui.md).
|
||||
|
||||
Existing evidence from the preceding implementation work:
|
||||
|
||||
- React state, a controlled text input, custom styles and clicks passed through
|
||||
the isolated plugin runtime into a host-owned UI. A test overlay remained above
|
||||
the plugin, with no iframe or native child view in the visible host window.
|
||||
- Seven declared views were exercised through the shared rendering fixture.
|
||||
This is not full product verification of seven navigation/lifecycle flows.
|
||||
- Focused policy tests covered unsafe content, URLs, CSS escapes, bounded data,
|
||||
ownership and update ordering. They do not constitute a complete security audit.
|
||||
- Plugin runtime tests passed (764 passed, 6 skipped), the Electron smoke suite
|
||||
passed with its development mode enabled, and the directory package build passed.
|
||||
- Whole-project TypeScript checking did not pass; the scoped recheck reported no
|
||||
errors in the newly introduced rendering files. Do not describe all checks as green.
|
||||
|
||||
The evidence above is historical. The current implementation and verification commands are in the host-rendered UI document; proposed capabilities below are not automatically shipped features.
|
||||
|
||||
## Prior art
|
||||
|
||||
- Obsidian lets plugins construct HTML in application-owned view containers.
|
||||
This gives natural stacking and broad flexibility, but plugins can access the
|
||||
file system; embedding their code directly in Netcatty's renderer would not
|
||||
retain Netcatty's existing isolation.
|
||||
https://docs.obsidian.md/Plugins/Getting%20started/Use%20React%20in%20your%20plugin
|
||||
https://docs.obsidian.md/Plugins/Vault
|
||||
- Shopify's MIT-licensed Remote DOM mirrors an isolated UI tree into a host DOM.
|
||||
It supports a remote DOM environment and examples using React. Its maintainers
|
||||
explicitly explain that a direct receiver is not by itself an adequate
|
||||
boundary: untrusted mutations need a separate validation and projection layer.
|
||||
The latest core release has configurable element/member restrictions and
|
||||
security fixes; they do not remove the need for a host threat model.
|
||||
https://github.com/Shopify/remote-dom
|
||||
https://github.com/Shopify/remote-dom/discussions/587
|
||||
https://github.com/Shopify/remote-dom/releases
|
||||
- VS Code web extensions use a separate worker with access to workspace files
|
||||
through explicit host APIs; their custom webviews are not equivalent to an
|
||||
application-owned native view.
|
||||
https://code.visualstudio.com/api/extension-guides/web-extensions
|
||||
https://code.visualstudio.com/api/extension-guides/webview
|
||||
|
||||
## Recommended architecture
|
||||
|
||||
1. Keep plugin React execution inside Netcatty's existing isolated, offline
|
||||
browser runtime (not an attached or visible webview). A plugin uses familiar
|
||||
React/JSX and produces a remote interface tree. The host owns the real DOM and
|
||||
controls lifecycle, sizing, focus, navigation and overlays.
|
||||
2. Validate every incoming mutation against a host-owned schema and a
|
||||
per-instance capability, then project it into a dedicated host-controlled
|
||||
surface. Do not forward arbitrary element properties, methods, event handlers,
|
||||
HTML or URLs. Reject oversized trees, pathological depth, stale instances and
|
||||
high-rate updates. Do not treat a third-party receiver as the security boundary.
|
||||
3. Provide ordinary layout/text/form HTML elements and scoped styling rather
|
||||
than a fixed component palette. The application must own the containing
|
||||
stacking context and clip output. Stylesheets must be parsed and scoped; deny
|
||||
unsafe URL loads, external imports, dangerous element types, navigation and
|
||||
escape from the plugin surface. Built-in theme tokens are available, not
|
||||
mandatory. Give errors for unsupported constructs instead of silently making
|
||||
a view appear successful.
|
||||
4. Route explicitly supported input, focus and keyboard events back to the same
|
||||
live view instance. Events carry only that view's own interaction data; never
|
||||
forward unrelated input, terminal output, host secrets or global DOM objects.
|
||||
Race, ordering, cancellation, multi-window and cleanup must be verified.
|
||||
5. Define one host-drawn view contract for every placement. Do not keep the
|
||||
attached-browser view as a compatibility path. UI registration grants no data
|
||||
capability; host-owned security dialogs must always remain above plugin UI.
|
||||
|
||||
## Limitations to make explicit
|
||||
|
||||
Fully unrestricted HTML/CSS is incompatible with a strong isolation claim in a
|
||||
shared DOM: URL-bearing resources can send information out; active elements can
|
||||
execute or navigate; global selectors and positioning can interfere with trusted
|
||||
UI. Safe styling can be broad, but is not literally unrestricted. Ordinary React
|
||||
component libraries that expect direct access to the real DOM, canvas, portals,
|
||||
measurements or browser globals may need adapters. The initial support matrix must
|
||||
be tested and published rather than promising full browser compatibility.
|
||||
|
||||
The local research package `@remote-dom/react` 1.2.2 declares React 17/18 peer
|
||||
support, while Netcatty uses React 19. The prototype has since verified a core-only DOM bridge with React 19 for its
|
||||
basic text input and button flow. Keep that evidence; do not restart the adapter
|
||||
selection merely because the separate React adapter declares different peers.
|
||||
Broader third-party component support remains a separate verification task.
|
||||
|
||||
## Verification gates
|
||||
|
||||
A working end-to-end example must use normal React state, a custom CSS layout,
|
||||
controlled input and buttons, render in Vault and terminal sidebar, and survive
|
||||
open/close, splits, tab switching and host dialogs. Security tests must exercise
|
||||
scripts, unsafe attributes and URLs, CSS imports and escape attempts, global
|
||||
positioning, host/other-plugin DOM access, network, view and window ownership,
|
||||
permissions, lock/disable/uninstall, message quotas, crash and late replies.
|
||||
These are requirements, not claims that the implementation is complete.
|
||||
|
||||
## Proposed decisions for the complete design
|
||||
|
||||
### Authoring freedom
|
||||
|
||||
Expose ordinary layout, text, form and styling primitives. Optional Netcatty
|
||||
components and theme values are conveniences, never the only way to build a UI.
|
||||
Do not constrain authors to a fixed collection of cards, forms or buttons.
|
||||
|
||||
Support flexible layout, responsive sizing, custom typography, local imagery,
|
||||
icons and animation as design requirements. The prototype's element allowlist
|
||||
is an initial safety boundary, not the final product scope. Package images and
|
||||
fonts need host-validated, package-bound resource handles with type/size limits;
|
||||
SVG needs an explicit inert subset or sanitized conversion before display.
|
||||
Never permit an arbitrary resource URL as a shortcut around network permissions.
|
||||
Use the mounted surface's size for responsive layouts, not just the app window.
|
||||
|
||||
Keep plugin CSS inside its surface. Contain positioning and stacking; sanitize
|
||||
styles before insertion. Shadow DOM helps isolate styles but is not a security
|
||||
boundary for JavaScript running in the same renderer. Never execute plugin code
|
||||
in the host renderer or expose host DOM references to it.
|
||||
|
||||
### Permanent registration, independent visibility and state
|
||||
|
||||
Registration lasts while the plugin is enabled. Closing a sidebar changes its
|
||||
visibility/layout; it does not unregister the plugin's entry. Users can discover,
|
||||
select, pin, reorder and split plugin views through the same controls as built-ins.
|
||||
|
||||
Separate three concepts: the registered entry, an open view instance, and the
|
||||
instance's current visibility. Tab switching and hiding should retain instance
|
||||
state within a bounded suspension policy. Explicitly closing a pane can dispose
|
||||
its instance without removing the registered entry. Persist layout and the
|
||||
plugin's own declared state, not arbitrary DOM or snapshots of host user data.
|
||||
|
||||
Each instance is bound to its own window, placement and session context. Moving
|
||||
or reopening a view must not expose another terminal's data. Disabled/uninstalled
|
||||
plugins lose their entries and active capabilities; locking clears sensitive
|
||||
rendered content and revokes in-flight operations.
|
||||
|
||||
### Dropdowns, dialogs and interaction
|
||||
|
||||
In-surface dropdowns can use ordinary plugin styles. For a popup that must extend
|
||||
beyond its pane, offer a host-owned overlay slot bound to that instance. The plugin
|
||||
still describes its own content and styling. Netcatty owns anchoring, clipping,
|
||||
focus return, Escape handling, lifetime and maximum stacking order. No plugin
|
||||
can overlay trusted permission prompts or supply its own permission approval UI.
|
||||
|
||||
Input state cannot be implemented as text-attribute mirroring alone. Define and
|
||||
test value/checked/selection updates, input composition, keyboard defaults, focus,
|
||||
labels, accessibility and paste. Send only the target instance's own events;
|
||||
never global keystrokes, clipboard reads, terminal text or the host document.
|
||||
Do not expose arbitrary synchronous DOM queries to make a component library work.
|
||||
Narrow measurements may describe the plugin's own layout only.
|
||||
|
||||
### Data protection
|
||||
|
||||
Default view creation exposes theme, locale, surface size, and the plugin's own
|
||||
state and interactions. It grants no access to Vault records, terminal output,
|
||||
files, passwords, tokens, other plugins or the host clipboard. Gate any optional
|
||||
data API independently and recheck grants at execution and delivery time.
|
||||
|
||||
Scope grants to concrete resources/actions. Prefer host-performed operations and
|
||||
opaque handles over disclosing secrets. Network authority is separate from data
|
||||
read authority: granting both can permit exfiltration. User input typed into a
|
||||
plugin is already disclosed to that plugin; no sandbox can undo that disclosure.
|
||||
|
||||
Audit message origin and instance identity, resource loaders, protocol requests,
|
||||
network egress including WebRTC, host callbacks and package replacement. Bound
|
||||
CPU, memory, updates and image decoding; isolate failures and permit termination.
|
||||
A hidden browser runtime is the prototype's execution container, not the visible
|
||||
UI. Retain it for now rather than replacing a tested path speculatively. Its
|
||||
full offline and process isolation claims must pass adversarial tests before
|
||||
settling the execution-container choice. An ordinary Node utility process or
|
||||
Worker alone is not a sufficient security argument.
|
||||
|
||||
### Next validation, without repeating completed work
|
||||
|
||||
1. Extend the existing Hello React sample with responsive narrow panes, local
|
||||
imagery, a dropdown, a checkbox, a select, Chinese input and focus navigation.
|
||||
2. Exercise actual Vault/sidebar/tab containers: close/reopen, tab switches,
|
||||
split/reorder, restart restoration and separate windows. Verify registration
|
||||
persistence separately from live view state.
|
||||
3. Add hostile-plugin cases for resource URLs, network bypass, stale identities,
|
||||
cross-instance access, app lock, disable/uninstall and resource exhaustion.
|
||||
4. Finalize the public contract from these results, then remove the obsolete
|
||||
attached-browser UI path and prototype-only dual-mode routing together.
|
||||
|
||||
Steps above are proposed follow-on work, not completed checks. No compatibility
|
||||
migration or parallel legacy renderer is part of the target design.
|
||||
@@ -1,76 +0,0 @@
|
||||
# Host-owned plugin settings
|
||||
|
||||
## Product boundary
|
||||
|
||||
Ordinary plugin pages retain freely authored layouts and styles, subject to the
|
||||
existing isolated runtime and host rendering checks. Settings are exclusively
|
||||
host-owned forms. There is no `settings` custom view location and no render,
|
||||
HTML, CSS, or callback field in setting declarations. The unpublished platform
|
||||
has no compatibility path for custom settings pages.
|
||||
|
||||
Plugins declare `contributes.settings`: stable ID, localized label/description,
|
||||
control, scope, default, options, constraints and optionally a localized `group`.
|
||||
The host groups rows by label in first-appearance order and keeps field order
|
||||
within each group. Labels/descriptions are plain text. Hidden settings are
|
||||
filtered before grouping; empty groups are not rendered. Ungrouped fields do not
|
||||
receive a redundant heading. Layout, controls, focus, error display, reset and
|
||||
persistence belong to Netcatty. Internal setting IDs are not displayed as help
|
||||
text. Permissions and uninstall remain separate host-owned controls.
|
||||
|
||||
Each of the six ordinary page placements offers the same circular settings
|
||||
button in its upper-right corner, only when the owning plugin has visible
|
||||
settings. The plugin list and package detail offer the same entry. Opening it
|
||||
uses a bounded, centered host dialog above the page; the page remains mounted.
|
||||
Escape closes only the settings dialog and returns focus to its entry. Forms
|
||||
always query the settings context, even when opened from Vault or the terminal.
|
||||
Disabling/removing the owning contribution closes the page entry's dialog.
|
||||
Package management and permission controls remain in the separate manager.
|
||||
|
||||
Text commits on leaving the field; switches and choices commit immediately.
|
||||
Writes go through the host's existing ownership, scope and validation checks.
|
||||
Rejected writes show an inline error and do not replace persisted values. Reset
|
||||
uses the declared default. Secret fields keep their existing secure storage and
|
||||
never expose their stored plaintext in renderer snapshots. Grouping grants no
|
||||
extra data access. Plugins use `settings.get` and `settings.onDidChange` for their
|
||||
own values; no plugin script needs to run to draw or edit this form.
|
||||
|
||||
Complex editors and previews belong in ordinary pages. A future settings link
|
||||
must be a host-rendered, validated reference to that plugin's own declared page;
|
||||
this change does not add an arbitrary callback/link escape hatch.
|
||||
|
||||
## Reference products (reviewed 2026-10-29)
|
||||
|
||||
- [Obsidian settings migration guide](https://docs.obsidian.md/plugins/guides/migrate-declarative-settings):
|
||||
its 1.13 declarative controls let the app handle rendering, validation,
|
||||
persistence and search. We adopt host-owned ordinary controls. We do not adopt
|
||||
executable custom render callbacks inside settings or its legacy fallback.
|
||||
- [VS Code configuration contributions](https://code.visualstudio.com/api/references/contribution-points#contributes.configuration):
|
||||
declarations provide types, defaults, descriptions, enums and scopes for a
|
||||
shared settings editor. We retain our existing setting ownership and scopes.
|
||||
- [Joplin setting items](https://joplinapp.org/api/references/plugin_api/interfaces/settingitem.html):
|
||||
labels, sections, choices, ranges and secure storage are declared separately
|
||||
from plugin page UI. We similarly keep storage and layout separate.
|
||||
|
||||
This is a UI restriction as well as a consistency decision. It does not replace
|
||||
runtime isolation or permission enforcement. We are not adding search, nested
|
||||
settings pages, or third-party custom setting controls in this change.
|
||||
|
||||
## Example and verification
|
||||
|
||||
`hello-react-host` 0.2.0 has six custom pages and four persistent host-owned
|
||||
settings: default name, greeting enabled, accent color and default note.
|
||||
Grouping is localized; changes propagate to its open pages. The example asks
|
||||
for access to its own settings only, not host records, files or credentials.
|
||||
|
||||
Contract tests reject custom settings placement/markup while accepting ordinary
|
||||
pages. UI tests cover placement, grouping, saving/reopening, failed writes and
|
||||
reset. Contribution tests cover localization and persistence without starting
|
||||
plugin UI. Real Electron coverage checks all six ordinary pages plus persisted
|
||||
preferences and live setting changes.
|
||||
|
||||
Validation for this change: 753 runtime checks passed (6 environment-specific
|
||||
skips), 92 contract/SDK/CLI checks passed, 23 host-drawn checks passed, and the
|
||||
real Electron fixture passed all six pages, form editing/reload, rejected writes,
|
||||
and live preference delivery. The macOS directory package build succeeded.
|
||||
The repository-wide TypeScript check still reports errors in other files; no
|
||||
diagnostics were reported for the files changed in this settings work.
|
||||
@@ -1,42 +0,0 @@
|
||||
# Plugin UI prior art
|
||||
|
||||
Research date: 2026-10-29. Supplement to the host-drawn UI design. Preserve the
|
||||
existing prototype and its test evidence. This is a new, unpublished feature;
|
||||
legacy view compatibility and migration are not requirements.
|
||||
|
||||
## Source comparison
|
||||
|
||||
| Reference | Verified design | What Netcatty can use |
|
||||
| --- | --- | --- |
|
||||
| [Shopify Remote DOM](https://github.com/Shopify/remote-dom) | Synchronizes a UI tree from a separate JavaScript environment into host-owned DOM. Includes worker/polyfill and React examples. Its current receiver supports explicit element/member restrictions. | The existing prototype's host-drawn direction. Treat Remote DOM as transport/rendering machinery, not a complete plugin sandbox. |
|
||||
| [VS Code web extensions](https://code.visualstudio.com/api/extension-guides/web-extensions) | Web extensions run in the browser extension host, lack Node APIs, and use VS Code APIs. They still have broad VS Code API access. | Separation of extension execution from application UI and explicit host APIs. The absence of Node alone does not imply no access to user files. |
|
||||
| [Joplin plugin architecture](https://joplinapp.org/help/dev/spec/plugins/) | Desktop plugin scripts run in a separate process with a proxy carrying API calls and events to the host. Its [custom panels](https://joplinapp.org/api/references/plugin_api/classes/joplinviewspanels.html) display HTML in webviews. | Independent plugin lifecycle and message-based APIs. Its panel display approach does not meet the requested host-drawn UI design. Process separation primarily helps containment and stability; permission safety depends on exposed APIs. |
|
||||
| [Obsidian plugin security](https://help.obsidian.md/Extending+Obsidian/Plugin+security) | Official documentation explicitly says plugins inherit the app's access levels and cannot reliably be restricted to individual permissions. Plugins can access files and the internet. | A reference for integration experience, not the requested data-isolation policy. This comparison concerns published plugin documentation, not an audit of open-source application internals. Obsidian's application core must not be presented as an open-source reference merely because its plugin API and examples are public. |
|
||||
| [Logseq official samples](https://github.com/logseq/logseq-plugin-samples) | Public examples cover custom themes, toolbar/editor interactions and custom pages. The [Hello World entry](https://github.com/logseq/logseq-plugin-samples/blob/master/logseq-hello-world/index.html) is an HTML document loading a plugin SDK and script. | Useful extension-point examples. These samples alone do not establish a security boundary or prove plugins cannot access user data; no such assurance is inferred here. |
|
||||
|
||||
## Important security distinction
|
||||
|
||||
In the [Remote DOM security discussion](https://github.com/Shopify/remote-dom/discussions/587),
|
||||
the implementation explanation describes an intermediate data tree, property
|
||||
allowlists and reprojection before trusted DOM changes. It explicitly warns
|
||||
against directly applying arbitrary remote patches. This is consistent with the
|
||||
existing Netcatty prototype's validation-before-rendering approach; it is not a
|
||||
reason to discard or rebuild that prototype.
|
||||
|
||||
The framework supports host-controlled UI elements, but its common examples use
|
||||
restricted custom components. Netcatty's broader HTML/CSS freedom is an additional
|
||||
product/security design responsibility, not a guarantee supplied by Shopify.
|
||||
|
||||
## Design consequence
|
||||
|
||||
Continue from the implemented host-drawn prototype. Keep developer freedom over
|
||||
ordinary layout, typography, colors and component composition. Independently
|
||||
control executable content, external resources, global positioning, host data
|
||||
access and event payloads. UI permission must not implicitly grant file,
|
||||
terminal, credential, clipboard or network access.
|
||||
|
||||
Retain the completed prototype tests as evidence of their specific scenarios.
|
||||
They are not proof of unrestricted React-library compatibility, complete UI
|
||||
lifecycle correctness or immunity to malicious plugins. The remaining work is
|
||||
to finish these boundaries and interaction semantics, with one intended UI
|
||||
architecture and no compatibility path for the unpublished alternatives.
|
||||
@@ -7,12 +7,8 @@ Provider subscription, terminal history reader or command execution API.
|
||||
|
||||
## Host-owned navigation
|
||||
|
||||
Visible plugin pages also have persistent entries in the side-panel toolbar,
|
||||
alongside built-in tools. They support the same ordering, overflow and
|
||||
show/hide customization, and reappear after closing and reopening the panel.
|
||||
Unavailable contributions are hidden without discarding their saved placement.
|
||||
The Extension pages menu in the terminal toolbar remains another entry point.
|
||||
Selecting a page switches the existing focused pane;
|
||||
An Extension pages menu in the normal terminal toolbar lists visible pages
|
||||
with their plugin source. Selecting one switches the existing focused pane;
|
||||
selecting an already-open tool focuses that pane. Existing split menus include
|
||||
the same contributions and enforce unique tools and the eight-pane limit.
|
||||
Host-generated `plugin-view:<namespaced-view-id>` keys cannot replace built-in
|
||||
@@ -34,21 +30,45 @@ the view context. Internal ownership IDs are used for host state scoping, not
|
||||
returned to the page or treated as resource grants. A future selected-session
|
||||
data API must establish its own live endpoint authority and permissions.
|
||||
|
||||
Changing terminal tabs or focused sessions releases the visible surface and
|
||||
retains its own logical instance within the bounded cache. Reopening uses the
|
||||
matching window/placement/session scope. Pane close changes layout; enabled
|
||||
plugin entries remain available. Disable/update/uninstall or runtime loss
|
||||
invalidate the affected instances immediately.
|
||||
Changing terminal tabs or workspace focus closes the old native view before
|
||||
the new one can receive focus. Session removal, pane close, withdrawal of a
|
||||
visible contribution, disable/update/uninstall and runtime loss remove the
|
||||
affected page. Unknown saved routes are pruned without opening them; restoring
|
||||
a layout never grants the page or any session-data permission. State buckets
|
||||
are for the plugin's own UI data, not an isolation boundary against that same
|
||||
plugin's already-authorized own-state API. Ordinary KV does not become synced.
|
||||
|
||||
PersistentSidePanelPortal moves the host DOM surface between its actual pane,
|
||||
magnification host and hidden parking host. Layout and focus follow the existing
|
||||
host pane system; no native geometry or focus bridge is needed. Host dialogs
|
||||
appear above the content. Plugin menus use host-owned overlay placement and
|
||||
are dismissed when a trusted modal appears.
|
||||
PersistentSidePanelPortal moves a stable mount between the actual pane,
|
||||
magnification host and hidden parking host. There is no fallback native view
|
||||
over the entire terminal layer. Native geometry follows host DOM relocation,
|
||||
resize and scrolling; duplicate geometry updates are suppressed. Hidden/inert
|
||||
or parked mounts cannot reveal a native view.
|
||||
|
||||
See [host-rendered UI](./host-rendered-ui.md) for authoring, state limits and
|
||||
security boundaries. The `session-scratchpad`, `hello-world-v2` and
|
||||
`hello-react-host` examples use this placement. Component tests exercise the
|
||||
real split tree, toolbar ordering, portal relocation, session switching and
|
||||
withdrawal. The Electron examples exercise visible controls through the shared
|
||||
host surface. Neither registration nor state retention grants session access.
|
||||
## Focus and security surfaces
|
||||
|
||||
The existing sandbox ViewHost, immutable package/protocol checks, own-command
|
||||
and view-state boundaries are reused. Embedded pages start hidden. Host
|
||||
dialogs and overlapping portals hide them, and main-process App Lock closes
|
||||
them. Escape is host-owned and closes the plugin pane.
|
||||
|
||||
Native WebContents focus is reported only to its actual owner's trusted
|
||||
renderer using a canonical instance-ID event. It updates the selected pane;
|
||||
it does not expose an Electron object to the plugin. Revealing multiple panes
|
||||
does not focus all of them: only the host-selected pane can request focus.
|
||||
The main process verifies actual owner, visibility, foreground and unlocked
|
||||
state before honoring that request. A plugin has no SDK method to invoke the
|
||||
host focus bridge. No session or page text is added to focus/audit logs.
|
||||
|
||||
## Example and evidence
|
||||
|
||||
`com.netcatty.session-scratchpad` 0.1.0 asks only for its own `views`. It saves,
|
||||
reopens and clears explicitly typed synthetic text. Package instructions and
|
||||
minimal native actions are in its README. Repository tests use real split
|
||||
tree, layout state, persistent portal, launcher and embedded-view components;
|
||||
host tests cover owner-bound focus, hidden/locked refusal and withdrawal.
|
||||
Feature and permission admission, limits, old APIs and previous Vault/command
|
||||
fixes are checked on the same stack. Independent review, exact-SHA CI and
|
||||
parent native QA remain separate acceptance gates recorded on the Draft PR.
|
||||
|
||||
ThemePack, selected context/terminal drafts and SFTP handles remain subsequent
|
||||
bounded stages. Adding a sidebar page does not count as implementing them.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Plugin UI contributions
|
||||
|
||||
Status: unpublished plugin platform; host-rendered browser views enabled in normal Settings
|
||||
Status: internal preview (`0.1.0-internal`), development gate only
|
||||
|
||||
Phase 4 of [#2269](https://github.com/binaricat/Netcatty/issues/2269)
|
||||
connects the manifest contribution contract to Netcatty's native UI and to a
|
||||
@@ -31,10 +31,6 @@ otherwise active plugin.
|
||||
|
||||
## Native settings
|
||||
|
||||
Settings are always host-rendered forms. Plugins cannot contribute custom HTML,
|
||||
CSS or executable renderers here. Optional localized `group` labels organize
|
||||
fields in declaration order. See [plugin-settings.md](plugin-settings.md).
|
||||
|
||||
Netcatty renders setting declarations with host-owned controls. The supported
|
||||
controls are switches, radio/select/multiselect, text and password fields,
|
||||
textarea, number and slider, color, font, file and directory paths, keybindings,
|
||||
@@ -114,17 +110,58 @@ contexts rather than sharing one renderer snapshot context. This gives the PR 5
|
||||
Terminal Provider layer a stable session identity without exposing xterm or
|
||||
allowing renderer-supplied plugin keys to override runtime-owned Context Keys.
|
||||
|
||||
## Host-rendered custom views
|
||||
## Sandboxed custom views
|
||||
|
||||
All custom views use the [host-rendered UI contract](./host-rendered-ui.md).
|
||||
Plugins author ordinary React or DOM and CSS in the isolated runtime. Netcatty
|
||||
validates and draws their content in the same DOM as its own UI. There is no
|
||||
visible browser surface, HTML entry document, or legacy renderer selection.
|
||||
Each open custom view is a lazily created `WebContentsView` with:
|
||||
|
||||
The host owns placement, registration, retention, overlays and window identity.
|
||||
Commands, settings and privileged capabilities keep their existing permission
|
||||
boundaries. The linked contract documents authoring, supported controls,
|
||||
resources, quotas, cleanup and current limitations.
|
||||
- `sandbox: true`, context isolation, web security, and no Node integration;
|
||||
- a private ephemeral session and a fixed black-hole proxy;
|
||||
- browser permissions, downloads, popups, webviews, drag navigation, and
|
||||
navigation away from the registered entry document denied;
|
||||
- a protocol token scoped to one plugin package;
|
||||
- a CSP with `connect-src 'none'`, no frames, workers, objects, media, forms, or
|
||||
base-URL changes; and
|
||||
- a Permissions Policy denying camera, microphone, location, display capture,
|
||||
USB, serial, HID, payment, fullscreen, and clipboard capabilities.
|
||||
|
||||
The protocol serves package files only. A view cannot load host runtime modules
|
||||
or another plugin's package. The view instance is bound to the Netcatty window
|
||||
that created it; another renderer window cannot resize, message, or close it.
|
||||
Owner closure, plugin disable, setup failure, and host shutdown all dispose the
|
||||
view and its protocol/session registrations.
|
||||
|
||||
Opening is generation-bound: the host revalidates the exact plugin version and
|
||||
runtime identity after package preparation and again after document loading,
|
||||
before attaching the `WebContentsView`. Runtime stop, crash, quarantine,
|
||||
disable, uninstall, or replacement therefore cancels an in-flight open. Every
|
||||
host-side close is also broadcast to the owning renderer so retained and active
|
||||
view state, including native tabs, is withdrawn immediately.
|
||||
|
||||
Views declaring `retainContextWhenHidden` are hidden without destroying their
|
||||
owner-bound `WebContentsView` and are restored with fresh bounds when reopened.
|
||||
Retained views are still disposed on owner shutdown, plugin disable, runtime
|
||||
quarantine, or host shutdown; the flag never extends ownership or permissions.
|
||||
|
||||
Views declaring `location: "tab"` participate in Netcatty's draggable native
|
||||
top-tab model, including neighbor activation, middle-click and Cmd/Ctrl+W close,
|
||||
and close-others/right/all actions. They do not fall through to the overlay
|
||||
surface used by non-tab locations.
|
||||
|
||||
The application-state lifecycle module combines a directly tested controller
|
||||
for active and retained instances, in-flight open tokens, early-close tombstones,
|
||||
and explicit-close handling with a hook that owns tab-catalog reconciliation,
|
||||
bounds, keyboard dispatch, and environment publication. The React component is
|
||||
rendering glue only. Contribution query changes expose a fail-closed empty
|
||||
snapshot while loading, but cannot mutate the native tab catalog until the
|
||||
matching query completes; opening or switching a tab therefore cannot withdraw
|
||||
an unconditional plugin tab during its own context refresh.
|
||||
|
||||
The preload exposes only `postMessage`, same-plugin `executeCommand`, state
|
||||
get/set, runtime messages, and environment changes. It caches environment
|
||||
updates before view code subscribes and uses an owner-checked getter as a
|
||||
fallback, so late subscribers still receive their initial environment. Messages,
|
||||
state, command arguments, and Context Key values use the exact bounded JSON
|
||||
value boundary rather than relying on `JSON.stringify` coercion.
|
||||
|
||||
## Themes, localization, and accessibility
|
||||
|
||||
@@ -140,7 +177,7 @@ preference, and subsequent environment changes. Theme-token mutations and
|
||||
accessibility media-query changes are observed while the host is open, rather
|
||||
than only at initial view creation. Netcatty retains the accessible
|
||||
name and close control around every custom view; modal placements use dialog
|
||||
semantics, while aside, panel, and tab placements use named regions.
|
||||
semantics, while aside, panel, tab, and settings placements use named regions.
|
||||
|
||||
## SDK surface
|
||||
|
||||
|
||||
@@ -1,20 +1,80 @@
|
||||
# Vault extension pages
|
||||
|
||||
Declare `location: "vault-page"`, require `views.vault-pages`, and request the
|
||||
`views` permission. Enabled entries appear in Vault's Extensions navigation,
|
||||
with localized labels and host-resolved icons. Listing does not activate them.
|
||||
`views.vault-pages` adds the `vault-page` view location. A package must require
|
||||
the feature and declare the existing `views` permission. Hosts without the
|
||||
feature reject the package deterministically. Existing view locations and API
|
||||
versions keep their behavior; there is no public plugin router or new Vault
|
||||
read/write permission.
|
||||
|
||||
Selecting an entry uses the built-in Vault rounded surface without an extra
|
||||
plugin title bar. Netcatty draws the content itself; host menus and dialogs use
|
||||
normal stacking. The plugin receives only the `netcatty.surface: vault-page`
|
||||
context and its own interactions. Selection shares no Vault or session records.
|
||||
## User workflow
|
||||
|
||||
Leaving and returning retains the view within the bounded lifecycle policy.
|
||||
Missing/disabled entries, invalid context or failed activation return to Hosts.
|
||||
Late failure from an older selection cannot replace a newer page. App Lock,
|
||||
disable and runtime termination clear the associated content and authority.
|
||||
Visible, enabled contributions appear in Vault's **Extensions** group. The
|
||||
host generates a namespaced route from the declared view ID, orders by declared
|
||||
order and ID, localizes the label, and resolves the declared icon through the
|
||||
existing safe icon pipeline. Built-in navigation and security entry points
|
||||
remain host-owned. Collapsed navigation retains source-labelled accessible
|
||||
buttons.
|
||||
|
||||
See [host-rendered UI](./host-rendered-ui.md) for the single rendering contract,
|
||||
resources, state retention and validation. `hello-vault`, `hello-world-v2`,
|
||||
`hello-react-host` and `vault-scratchpad` demonstrate this placement. The scratchpad
|
||||
saves only text entered in its own page, without creating a Vault Note.
|
||||
Selecting a page mounts its existing sandbox WebContentsView in the Vault main
|
||||
content area. It does not create a top tab, use an aside overlay, open a
|
||||
connection, or share host/session/Vault records. The trusted host context
|
||||
contains only `netcatty.surface: vault-page`. The manager's existing Open action
|
||||
can select the same page through an internal host event; this is not exposed
|
||||
as a generic SDK navigation capability.
|
||||
|
||||
The `vault-scratchpad` browser example lets the user type synthetic text, save
|
||||
it in the plugin's own view state, return to Hosts, reopen, and clear it. It
|
||||
does not create a Vault Note or enable automatic cloud sync. The fixture asks
|
||||
for only `views`; granting it does not approve another plugin or data access.
|
||||
|
||||
## Ownership and lifecycle
|
||||
|
||||
The host owns the route and validates the selected contribution against the
|
||||
current visible catalog. Missing IDs, disable/uninstall/update, a false `when`,
|
||||
failed activation or a host close return to Hosts. Focus restoration checks
|
||||
the current section and active tab so an old fallback cannot steal focus after
|
||||
a newer selection. Catalog listing does not activate a plugin.
|
||||
|
||||
The embedded host reuses PluginViewHost, its ephemeral session, CSP, no-Node
|
||||
preload, protocol validation, own-command checks, and bounded own view state.
|
||||
No DOM or Electron object enters the SDK. A window-route-and-slot scope separates
|
||||
this page's state from other view placements; actual BrowserWindow ownership is
|
||||
validated separately and does not rely on that state key. This initial embedded slice
|
||||
closes on navigation away even if `retainContextWhenHidden` is declared.
|
||||
|
||||
Each opening reserves an instance ID owned by its actual BrowserWindow before
|
||||
asynchronous activation. Close can cancel that reservation before native
|
||||
construction. Owner closure, shutdown and renderer cleanup prevent a late open
|
||||
from attaching; late responses are also closed by the renderer lifecycle
|
||||
controller. Current plugin version/runtime and `when` are checked before
|
||||
attachment and on subsequent contribution changes. Existing owner checks apply
|
||||
to bounds, visibility, close and view-state IPC.
|
||||
|
||||
Host reload/renderer exit and a view renderer crash close the associated native
|
||||
page. Plain Escape in a Vault page is handled by the host and returns to Hosts;
|
||||
it does not dispatch a plugin command or expose a general navigation API.
|
||||
|
||||
Native child views sit above React portals. A Vault page starts hidden; the
|
||||
trusted renderer reveals it only after checking the mount and host dialogs,
|
||||
menus, popovers, overlay panels and lock surfaces. Overlapping host surfaces
|
||||
hide the page. App Lock additionally closes all hosted plugin views and
|
||||
cancels pending opens in the main process; showing a view while locked fails.
|
||||
|
||||
The shared manifest bound remains 128 views per package. Only one Vault page is
|
||||
mounted per owner; listing and routing do not create a background view queue.
|
||||
View-state JSON size limits and existing runtime/permission quotas remain in
|
||||
force. No page text or state is added to audit logs by this adapter.
|
||||
|
||||
## Validation status
|
||||
|
||||
Source tests cover normal catalog selection, the actual embedded content slot,
|
||||
host-modal occlusion, synchronous tab-away cleanup, withdrawn and invalid
|
||||
routes, internal host selection, delayed open cancellation, feature/permission
|
||||
admission, host ownership, `when` withdrawal and App Lock. Production build,
|
||||
aggregate host tests and precise-SHA CI are reported on the accompanying Draft
|
||||
PR. Parent native acceptance must use the packed browser example through
|
||||
normal Settings and record its exact source SHA/package hash. Unit tests do
|
||||
not substitute for that native acceptance.
|
||||
|
||||
Terminal sidebar pages and selected-session DTOs remain separate stages. This
|
||||
page location does not make those capabilities available.
|
||||
@@ -1,47 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildMonitorDiskListLayout, sortMonitorDisks } from "./monitorDisks";
|
||||
|
||||
test("list mounts put root first, group system paths, and naturally sort data paths", () => {
|
||||
const paths = ["/bd10", "/home", "/var/log", "/bd03", "/usr/local", "/boot/efi", "/", "/srv", "/bd02", "/etc", "/opt", "/tmp", "/boot", "/bd01", "/var", "/usr", "/bd2/archive10", "/bd2/archive2"];
|
||||
const disks = paths.map(mountPoint => ({ mountPoint }));
|
||||
assert.deepEqual(sortMonitorDisks(disks).map(disk => disk.mountPoint), [
|
||||
"/", "/boot", "/boot/efi", "/etc", "/usr", "/usr/local", "/var", "/var/log", "/home", "/opt", "/srv", "/tmp",
|
||||
"/bd01", "/bd02", "/bd2/archive2", "/bd2/archive10", "/bd03", "/bd10",
|
||||
]);
|
||||
assert.deepEqual(disks.map(disk => disk.mountPoint), paths);
|
||||
});
|
||||
|
||||
test("healthy system mounts collapse while root and naturally sorted data mounts remain visible", () => {
|
||||
const disks = ["/bd10", "/boot/efi", "/", "/bd02", "/boot"].map(mountPoint => ({ mountPoint, percent: 80 }));
|
||||
const layout = buildMonitorDiskListLayout({ disks });
|
||||
assert.deepEqual(layout.rows.map(row => row.kind === "disk" ? row.disk.mountPoint : "system"), ["/", "/bd02", "/bd10", "system"]);
|
||||
assert.equal(layout.forcedExpanded, false);
|
||||
assert.equal(layout.expanded, false);
|
||||
assert.deepEqual(layout.rows.at(-1), { kind: "system", count: 2, expanded: false, alert: false });
|
||||
const expanded = buildMonitorDiskListLayout({ disks }, true);
|
||||
assert.deepEqual(expanded.rows.slice(-2).map(row => row.kind === "disk" && row.disk.mountPoint), ["/boot", "/boot/efi"]);
|
||||
});
|
||||
|
||||
test("only finite system usage above 80 forces expansion and highlights the affected mount", () => {
|
||||
for (const percent of [80.1, 81, 100]) {
|
||||
const layout = buildMonitorDiskListLayout({ disks: [
|
||||
{ mountPoint: "/", percent: 99 }, { mountPoint: "/boot", percent: 30 },
|
||||
{ mountPoint: "/boot/efi", percent }, { mountPoint: "/data", percent: 99 },
|
||||
] });
|
||||
assert.equal(layout.forcedExpanded, true);
|
||||
assert.equal(layout.expanded, true);
|
||||
assert.deepEqual(layout.rows.filter(row => row.kind === "disk" && row.alert)
|
||||
.map(row => row.kind === "disk" && row.disk.mountPoint), ["/boot/efi"]);
|
||||
}
|
||||
for (const percent of [null, NaN, Infinity, 0, 80]) {
|
||||
assert.equal(buildMonitorDiskListLayout({ disks: [{ mountPoint: "/boot", percent }] }).forcedExpanded, false);
|
||||
}
|
||||
assert.equal(buildMonitorDiskListLayout({ disks: [{ mountPoint: "/", percent: 99 }, { mountPoint: "/data", percent: 99 }] }).forcedExpanded, false);
|
||||
});
|
||||
|
||||
test("similar data mount names are not treated as system directories", () => {
|
||||
const disks = ["/various", "/data", "/boot-data", "/var/log", "/"].map(mountPoint => ({ mountPoint }));
|
||||
assert.deepEqual(sortMonitorDisks(disks).map(disk => disk.mountPoint), ["/", "/var/log", "/boot-data", "/data", "/various"]);
|
||||
assert.deepEqual(sortMonitorDisks([]), []);
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
// Product display order; descendants stay with their system mount group.
|
||||
const SYSTEM_MOUNTS = ["/", "/boot", "/etc", "/usr", "/var", "/home", "/opt", "/srv", "/tmp"];
|
||||
const mountNames = new Intl.Collator("en", { numeric: true });
|
||||
|
||||
function mountPriority(mountPoint: string): number {
|
||||
const rank = SYSTEM_MOUNTS.findIndex(path =>
|
||||
mountPoint === path || (path !== "/" && mountPoint.startsWith(`${path}/`)),
|
||||
);
|
||||
return rank < 0 ? SYSTEM_MOUNTS.length : rank;
|
||||
}
|
||||
|
||||
export interface MonitorDiskRow {
|
||||
mountPoint: string;
|
||||
percent: number | null;
|
||||
used?: number | null;
|
||||
total?: number | null;
|
||||
diskReadSpeed?: number | null;
|
||||
diskWriteSpeed?: number | null;
|
||||
}
|
||||
|
||||
export type MonitorDiskListRow =
|
||||
| { kind: "disk"; disk: MonitorDiskRow; alert: boolean }
|
||||
| { kind: "system"; count: number; expanded: boolean; alert: boolean };
|
||||
|
||||
export interface MonitorDiskListStats {
|
||||
disks: readonly MonitorDiskRow[];
|
||||
diskPercent?: number | null;
|
||||
diskUsed?: number | null;
|
||||
diskTotal?: number | null;
|
||||
}
|
||||
|
||||
export function isMonitorSystemDisk(mountPoint: string): boolean {
|
||||
const priority = mountPriority(mountPoint);
|
||||
return priority > 0 && priority < SYSTEM_MOUNTS.length;
|
||||
}
|
||||
|
||||
export function monitorSystemDiskAlert(disk: MonitorDiskRow): boolean {
|
||||
return isMonitorSystemDisk(disk.mountPoint)
|
||||
&& typeof disk.percent === "number" && Number.isFinite(disk.percent) && disk.percent > 80;
|
||||
}
|
||||
|
||||
/** One layout drives both columns, including a matching slot for the group header. */
|
||||
export function buildMonitorDiskListLayout(stats?: MonitorDiskListStats | null, manuallyExpanded = false) {
|
||||
const disks = sortMonitorDisks(stats?.disks.length ? stats.disks : [{
|
||||
mountPoint: "/", percent: stats?.diskPercent ?? null,
|
||||
used: stats?.diskUsed ?? null, total: stats?.diskTotal ?? null,
|
||||
}]);
|
||||
const system = disks.filter(disk => isMonitorSystemDisk(disk.mountPoint));
|
||||
const forcedExpanded = system.some(monitorSystemDiskAlert);
|
||||
const expanded = forcedExpanded || manuallyExpanded;
|
||||
const rows: MonitorDiskListRow[] = disks
|
||||
.filter(disk => !isMonitorSystemDisk(disk.mountPoint))
|
||||
.map(disk => ({ kind: "disk", disk, alert: false }));
|
||||
if (system.length) {
|
||||
rows.push({ kind: "system", count: system.length, expanded, alert: forcedExpanded });
|
||||
if (expanded) rows.push(...system.map(disk => ({ kind: "disk" as const, disk, alert: monitorSystemDiskAlert(disk) })));
|
||||
}
|
||||
return { rows, expanded, forcedExpanded };
|
||||
}
|
||||
|
||||
export type MonitorDiskListLayout = ReturnType<typeof buildMonitorDiskListLayout>;
|
||||
|
||||
/** Sort a copy so capacity cards and other consumers retain the reported order. */
|
||||
export function sortMonitorDisks<T extends { mountPoint: string }>(disks: readonly T[]): T[] {
|
||||
return [...disks].sort((a, b) =>
|
||||
mountPriority(a.mountPoint) - mountPriority(b.mountPoint)
|
||||
|| mountNames.compare(a.mountPoint, b.mountPoint),
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { formatMonitorRate } from "./monitorFormatting";
|
||||
|
||||
test("disk and network rates adapt to decimal KB/MB/GB units", () => {
|
||||
for (const [value, expected] of [
|
||||
[0, "0.0 KB/s"], [500, "0.5 KB/s"], [1000, "1.0 KB/s"],
|
||||
[999000, "999.0 KB/s"], [1e6, "1.0 MB/s"], [2.5e6, "2.5 MB/s"],
|
||||
[1e9, "1.0 GB/s"], [12e9, "12.0 GB/s"],
|
||||
] as const) assert.equal(formatMonitorRate(value), expected);
|
||||
for (const value of [null, undefined, NaN, Infinity, -1]) {
|
||||
assert.equal(formatMonitorRate(value), "--");
|
||||
}
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
/** Decimal byte-rate units, shared by disk I/O and network monitoring. */
|
||||
export function formatMonitorRate(value: number | null | undefined): string {
|
||||
if (value == null || !Number.isFinite(value) || value < 0) return "--";
|
||||
if (value >= 1e9) return `${(value / 1e9).toFixed(1)} GB/s`;
|
||||
if (value >= 1e6) return `${(value / 1e6).toFixed(1)} MB/s`;
|
||||
return `${(value / 1e3).toFixed(1)} KB/s`;
|
||||
}
|
||||
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user