Compare commits

..
292 changed files with 4279 additions and 9846 deletions

No files matched your search

-3
View File
@@ -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/
-5
View File
@@ -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.
+1 -2
View File
@@ -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');
-3
View File
@@ -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)),
+1 -10
View File
@@ -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.',
-4
View File
@@ -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",
+1 -10
View File
@@ -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.',
-4
View File
@@ -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",
+1 -10
View File
@@ -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': 'Включённые плагины не предоставляют настройки.',
-4
View File
@@ -1,8 +1,4 @@
export const ruMonitorMessages = {
"monitor.systemDisks": "Системные разделы ({count})",
"monitor.systemDisksAlert": "Использование системного раздела превышает 80 %; группа раскрыта автоматически.",
"monitor.diskIoTotal": "Всего",
"monitor.partitionIoHint": "Ввод-вывод блочного устройства точки монтирования. Точки одного устройства показывают одинаковые скорости. Недоступно до двух корректных замеров или без определённого устройства; macOS не поддерживается.",
"monitor.diskIo": "Дисковый ввод-вывод",
"monitor.diskRead": "Чтение",
"monitor.diskWrite": "Запись",
+1 -10
View File
@@ -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": "写入",
+1 -10
View File
@@ -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();
}
-5
View File
@@ -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: [] });
});
-41
View File
@@ -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; },
};
}
+133 -2
View File
@@ -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);
});
+246
View File
@@ -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();
});
-78
View File
@@ -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();
}
});
+4 -40
View File
@@ -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(
+119
View File
@@ -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 };
}
-17
View File
@@ -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;
}),
};
}
-11
View File
@@ -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,
};
}
+4 -6
View File
@@ -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 };
}
+209 -9
View File
@@ -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
View File
@@ -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;
+2 -42
View File
@@ -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,
+3 -8
View File
@@ -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);
+7 -7
View File
@@ -147,8 +147,8 @@ test('cold plugin host reaches the local fixture through the actual main provide
await service.manager.initialize();
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
const sample = path.join(rootPath, 'examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', '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);
+8 -8
View File
@@ -90,8 +90,8 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
for (const file of ['netcatty.plugin.json', '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();
+1 -21
View File
@@ -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>));
+35 -21
View File
@@ -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); }
}
});
+37 -14
View File
@@ -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); }
}
});
+11 -20
View File
@@ -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>;
}
+5 -17
View File
@@ -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); }
}
});
-120
View File
@@ -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} />;
}
-275
View File
@@ -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>)}</>;
}
+24 -17
View File
@@ -1,40 +1,40 @@
import { useMemo } from 'react';
import { Blocks } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { activeTabStore } from '../../application/state/activeTabStore';
import { subscribeSidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { collectPluginTerminalSidebarPages, PLUGIN_TERMINAL_SIDEBAR_CONTEXT, requestOpenPluginTerminalSidebar,
type PluginTerminalSidebarPage } from '../../application/state/usePluginTerminalSidebar';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { 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>;
}
+17 -7
View File
@@ -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>;
}
-41
View File
@@ -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, /&lt;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);
+6 -6
View File
@@ -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 -10
View File
@@ -1,6 +1,6 @@
import React from 'react';
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { PluginReadme } from './PluginReadme';
@@ -12,12 +12,3 @@ test('README never creates active HTML, remote media or script links', () => {
assert.match(html, /rel="noopener noreferrer"/);
assert.match(html, /&lt;script&gt;/);
});
test('README only renders registered local rasterized images and placeholders otherwise', () => {
const path = `/assets/${'a'.repeat(64)}.png`;
const html = renderToStaticMarkup(<PluginReadme markdown={`![ok](${path}) ![tracker](https://evil.test/x) ![bad](/unknown)`} images={{ [path]: 'data:image/png;base64,YQ==' }} />);
assert.match(html, /src="data:image\/png;base64,YQ=="/);
assert.match(html, /\[tracker\]/);
assert.match(html, /\[bad\]/);
assert.doesNotMatch(html, /evil\.test/);
});
+6 -11
View File
@@ -2,9 +2,9 @@ import React, { useMemo } from 'react';
import { fromMarkdown } from 'mdast-util-from-markdown';
import type { RootContent, Root } from 'mdast';
function renderNode(node: Root | RootContent, key: number, 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 = {
+387 -14
View File
@@ -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>
+10 -4
View File
@@ -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>
);
}
+2 -4
View File
@@ -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>
+1 -1
View File
@@ -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();
}
});
-59
View File
@@ -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>
);
}
+15 -26
View File
@@ -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>
);
+2 -2
View File
@@ -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>
+6 -44
View File
@@ -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/);
});
+10 -16
View File
@@ -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(); });
}
});
+2 -24
View File
@@ -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.
-163
View File
@@ -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.
-76
View File
@@ -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.
+42 -22
View File
@@ -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.
+52 -15
View File
@@ -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
+75 -15
View File
@@ -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.
-47
View File
@@ -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([]), []);
});
-69
View File
@@ -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),
);
}
-14
View File
@@ -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), "--");
}
});
-7
View File
@@ -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