Compare commits

...
Author SHA1 Message Date
陈大猫andbinaricat 780d297591 fix(terminal): forward xterm onBinary for legacy mouse (#3811) (#4066)
* fix(terminal): forward xterm onBinary for legacy mouse (#3811)

Legacy DEC mouse (ttymouse=xterm2) is emitted via onBinary; only onData
was wired. Preserve 0-255 through the write path with a binary/latin1
Buffer so high coordinate bytes are not UTF-8-corrupted.

* chore: retrigger CI after lint-and-test runner shutdown (exit 143)

* chore: retrigger CI after lint-and-test runner shutdown (2)

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-10-30 07:11:27 -07:00
GOUKI9999 b496b00d2f feat: bounded recovery for scoped disconnected sessions (#3935)
Contributor GOUKI9999 session_reconnect/session_recover. Maintainer gate fix: do not report ready while still offline/connecting (pending instead); connected keeps advisory shellReady; regenerate capability tool specs. Unit tests 12/12. Electron smoke PASS (SSH shell path). Residual P2 hung-worker readiness left timeout-bounded. No release.
2026-10-29 19:32:02 -07:00
Joseph Hau a8c69b8e11 docs: propose Context Chips for the terminal compose bar (#4059)
* docs: propose terminal compose bar Context Chips

* docs: fix Context Chips baseline and demo anchoring
2026-10-30 10:09:24 +08:00
6d687d570f fix(sftp): keep non-ASCII local paths intact in Windows compressed upload (#4057)
* fix(sftp): keep non-ASCII local paths intact in Windows compressed upload

Windows tar.exe converts argv to the ANSI code page, turning non-ASCII
characters into '?'. Run tar with cwd instead of -C and stream the
archive through stdout instead of -f, so local paths never pass through
argv. Folders whose own name is non-ASCII fall back to per-file upload
on Windows.

* fix(sftp): exclude in-tree tar archive under GNU tar stdout

* fix(sftp): resolve trusted local tar before cwd switch

Use the Windows System32 tar.exe for local availability checks and compression so an upload sibling tar.exe cannot hijack execution. Keep Unix tar lookup unchanged and update command assertions.

Residual P2: exclude patterns still interpret glob metacharacters in archive paths; glob escaping is intentionally deferred.

* ci: retrigger lint-and-test after stuck runner

* ci: retrigger after unrelated plugin manager UI flake

---------

Co-authored-by: noon.li <214648221+lyon-le@users.noreply.github.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-29 05:38:59 -07:00
陈大猫 a2f23054df fix(sftp): keep non-ASCII local paths intact in Windows compressed upload (#4058)
Gate for contributor #4057 (same tip). cwd+stdout FD instead of -C/-f for Windows tar.exe ANSI argv; GNU tar --exclude for in-tree archive; resolve System32 tar.exe before cwd switch.

Local Codex CLEAN on 73ff25a31 (residual P2 glob-escape deferred). Electron smoke PASS NC_OUT pr-4057-73ff25a31. CI green on tip after plugin-manager focus flake retrigger.
2026-10-29 05:38:44 -07:00
Joseph Hau 0fe069e575 fix(terminal): preserve output timestamps after clear (#4056)
* fix(terminal): preserve output timestamps after clear

* fix(terminal): limit timestamp checkpoints to erases
2026-10-29 02:08:05 -07:00
GOUKI9999and陈大猫 1809b7dc02 fix(sync): keep full AI chat history when cloud sync is enabled (#4047)
* fix(sync): keep full history when AI chat sync is enabled

Retain all messages when cloud AI-session sync is enabled; keep the 500-message limit for local-only chats. Cover both settings with a history-retention regression test.

Rebase onto origin/main and retain #4045 preserveActive behavior and its no-cancel regression coverage unchanged. Drop the duplicate wait-before-merge loop, Waiting-for-Agent UI and i18n, sync status and streaming wait helpers, hasActiveTurn, wait-only tests, and docs plan.

Validation: all 23 tests pass across useAIState.historyRetention.test.ts, syncPayload.aiSessions.test.ts, and agentStop.test.ts. Targeted ESLint and git diff --check pass.

* chore: retrigger CI after cancelled lint-and-test

Previous Actions run 40824800880 cancelled mid plugin-contract step; windows jobs were already green. No code change.

* chore: retrigger CI after cancelled lint-and-test (Codex local PASS)

---------

Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-28 23:58:42 -07:00
陈大猫 d5a03606b7 feat: rebuild plugin UI with host rendering and standard settings (#4053)
* feat: rebuild plugin pages with host rendering and native settings

* fix(plugins): contain host-view errors, isolate editing, and migrate fixtures

* fix(plugins): migrate remaining permission fixture hosts
2026-10-28 23:08:31 -07:00
陈大猫 7cd57adcea fix(terminal): PTY OSC7 install when exec cannot verify shell (#4052) (#4054)
Fixes #4052. Electron smoke PASS; Codex CLEAN; CI green.
2026-10-29 12:48:32 +08:00
279 changed files with 11199 additions and 4224 deletions

No files matched your search

+3
View File
@@ -170,3 +170,6 @@ build_with_vs2022.bat
/resources/et/*/et.exe
/resources/et/*/et-*-dlls/
/resources/et/*/*.dll
# Local plugin example staging and bundles
/examples/plugins/*/.package/
+5
View File
@@ -127,6 +127,11 @@ second private RPC shape when extending the host.
- `PluginRpcRouter` owns correlation, cancellation, deadlines, stream credit and
protocol-failure containment. Runtime identity is assigned by the host and is
never accepted from request parameters.
- Custom views use only host-rendered DOM: plugin scripts stay in the isolated
runtime; the host validates incoming trees, styles, resources and events before
rendering. Never reintroduce native child views, iframe UI, or plugin code in
the host renderer. See `docs/plugin-platform/host-rendered-ui.md` for authoring,
lifecycle, limits and verification.
- App quit goes through `runPluginShutdown()` after the dirty-editor guard; do
not add another independent quit interception path.
+2 -1
View File
@@ -16,6 +16,7 @@ import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardin
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveBatchTabCloseFocus } from '../state/pluginViewTabStore';
type AppContextGetter = () => Record<string, any>;
@@ -264,7 +265,7 @@ export function handleGlobalHotkeyKeyDownImpl(getCtx: AppContextGetter, e: Keybo
}
}
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable || isPluginViewEditingTarget(target);
const isMonacoElement =
target instanceof HTMLElement &&
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
+20
View File
@@ -19,6 +19,8 @@ import {
} from '../state/connectionLogsStore';
import { getNotesSnapshot } from '../state/notesStore';
import { useVaultAgentBridge } from '../state/useVaultAgentBridge';
import { terminalReconnectRegistry } from '../state/terminalReconnectRegistry';
import { requestAgentSessionReconnect } from '../state/agentSessionReconnect';
import { useWindowControls } from '../state/useWindowControls';
import { useTerminalKeyboardFocus } from '../state/useTerminalKeyboardFocus';
import { editorTabStore, useEditorTabChromeList } from '../state/editorTabStore';
@@ -59,6 +61,7 @@ import { collectSessionIds } from '../../domain/workspace';
import { resolveAgentHostWorkspace } from '../../domain/agentHostWorkspace';
import { useHostOpenSessionReuse } from '../state/useHostOpenSessionReuse';
import type { PaneMagnificationController } from '../../domain/paneMagnification';
import { isPluginViewEditingTarget } from '../state/pluginKeybindings';
import { resolveCloseIntent } from '../state/resolveCloseIntent';
import { resolveSnippetsShortcutIntent } from '../state/resolveSnippetsShortcutIntent';
import { isPrimaryModifierWBinding, resolveWindowCommandCloseIntent } from '../state/windowCommandClose';
@@ -1156,6 +1159,8 @@ export function AppSideEffects() {
const topmostOpenDialog = openDialogs[openDialogs.length - 1] ?? null;
const topmostDialogClose = topmostOpenDialog?.querySelector<HTMLElement>('[data-dialog-close="true"]');
if (!topmostDialogClose && isPluginViewEditingTarget(document.activeElement)) return;
const intent = resolveWindowCommandCloseIntent({
activeTabId: activeTabStore.getActiveTabId(),
editorTabIds: editorTabs.map((tab) => toEditorTabId(tab.id)),
@@ -1524,6 +1529,19 @@ export function AppSideEffects() {
sessions, hosts, keys, identities, resolveEffectiveHost,
});
const reconnectSessionForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
return requestAgentSessionReconnect(sessionId, session?.status, terminalReconnectRegistry);
}, [sessions]);
const inspectSessionRecoveryForVaultAgent = useCallback((sessionId: string) => {
const session = sessions.find((entry) => entry.id === sessionId);
if (!session) return { ok: false, error: `Session "${sessionId}" was not found.` };
const live = terminalReconnectRegistry.inspect(sessionId);
return { ok: true, sessionId, status: session.status, waitingForInput: false,
reconnecting: false, reconnectAllowed: false, ...live };
}, [sessions]);
const closeSessionForVaultAgent = useCallback((sessionId: string) => {
if (!sessions.some((session) => session.id === sessionId)) {
return { ok: false as const, error: `Session "${sessionId}" was not found.` };
@@ -1568,6 +1586,8 @@ export function AppSideEffects() {
openHost: openHostForVaultAgent,
findExistingSessionForHost: findExistingSessionForHostForVaultAgent,
closeSession: closeSessionForVaultAgent,
reconnectSession: reconnectSessionForVaultAgent,
inspectSessionRecovery: inspectSessionRecoveryForVaultAgent,
getScriptSessionMeta: (sessionId) => sessions.find((session) => session.id === sessionId),
});
+1
View File
@@ -1401,6 +1401,7 @@ Highlight the focused split pane:
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Plugin settings",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.',
+1
View File
@@ -1401,6 +1401,7 @@ Resalta el panel dividido enfocado:
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.configuration": "Ajustes del plugin",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
+1
View File
@@ -1266,6 +1266,7 @@ export const ruCoreMessages: Messages = {
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины",
"settings.plugins.configuration": "Настройки плагина",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
+1
View File
@@ -1316,6 +1316,7 @@ Vaults 标签保持扁平,不使用这块背景。
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件设置",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。',
+1
View File
@@ -1287,6 +1287,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.configuration": "插件設定",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestAgentSessionReconnect } from './agentSessionReconnect';
test('reconnect preserves identity and only dispatches disconnected eligible sessions', () => {
let calls = 0;
const registry = { isActive: () => false, requestIfAvailable: () => { calls++; return true; } };
assert.equal(requestAgentSessionReconnect('s', undefined, registry).ok, false);
assert.equal(requestAgentSessionReconnect('s', 'connected', registry).status, 'connected');
assert.equal(requestAgentSessionReconnect('s', 'connecting', registry).started, false);
const result = requestAgentSessionReconnect('s', 'disconnected', registry);
assert.deepEqual(result, { ok: true, started: true, sessionId: 's', status: 'reconnecting', shellReady: false });
assert.equal(calls, 1);
});
test('auto/manual reconnect and unavailable terminal cannot queue another recovery', () => {
const registry = { isActive: () => true, requestIfAvailable: () => { throw Error('must not dispatch'); } };
assert.equal(requestAgentSessionReconnect('s', 'disconnected', registry).started, false);
assert.equal(requestAgentSessionReconnect('s', 'disconnected', { isActive: () => false, requestIfAvailable: () => false }).code, 'RECONNECT_UNAVAILABLE');
});
@@ -0,0 +1,21 @@
type ReconnectRegistry = {
isActive: (sessionId: string) => boolean;
requestIfAvailable: (sessionId: string) => boolean;
};
/** Synchronous dispatch only: no queued recovery after scope/terminal disposal. */
export function requestAgentSessionReconnect(
sessionId: string,
status: string | undefined,
registry: ReconnectRegistry,
): Record<string, unknown> {
if (!status) return { ok: false, started: false, error: `Session "${sessionId}" was not found.` };
if (status === 'connected') return { ok: true, started: false, sessionId, status: 'connected', shellReady: false };
if (status === 'connecting' || registry.isActive(sessionId)) {
return { ok: true, started: false, sessionId, status: 'reconnecting' };
}
if (status !== 'disconnected' || !registry.requestIfAvailable(sessionId)) {
return { ok: false, started: false, sessionId, code: 'RECONNECT_UNAVAILABLE', error: 'Reconnect is unavailable: terminal may be unmounted, waiting for authentication, or recovering. Inspect session state and the Netcatty UI.' };
}
return { ok: true, started: true, sessionId, status: 'reconnecting', shellReady: false };
}
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
test('native modal yields to the host chooser, restores on dismissal, and never restores after cleanup', async () => {
const dom = new JSDOM('<body><div role="dialog" aria-modal="true"><div id="mount"></div></div></body>');
try {
const { document } = dom.window;
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(document.getElementById('mount')!, (visible) => calls.push(visible));
const flush = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, []); // Its own modal does not hide the plugin.
const chooser = document.createElement('div');
chooser.dataset.hostSecuritySurface = '';
document.body.append(chooser);
await flush(); assert.deepEqual(calls, [false]);
chooser.remove(); await flush(); assert.deepEqual(calls, [false, true]);
document.body.append(chooser); await flush();
assert.deepEqual(calls, [false, true, false]);
stop(); chooser.remove(); await flush();
assert.deepEqual(calls, [false, true, false]);
} finally { dom.window.close(); }
});
test('hidden security surfaces do not occlude a native view', async () => {
const dom = new JSDOM('<body><div id="mount"></div><div hidden><div data-host-security-surface></div></div></body>');
try {
const calls: boolean[] = [];
const stop = observePluginHostSecurityVisibility(dom.window.document.getElementById('mount')!, (visible) => calls.push(visible));
assert.deepEqual(calls, []);
dom.window.document.querySelector('[hidden]')!.removeAttribute('hidden');
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.deepEqual(calls, [false]); stop();
} finally { dom.window.close(); }
});
@@ -1,21 +0,0 @@
/** Native plugin views must yield while a host-owned security dialog is open. */
export function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
// The view lifecycle owns close/retention. Cleanup must never reveal an old view.
return () => observer.disconnect();
}
+5
View File
@@ -33,6 +33,7 @@ const NAMED_KEYS = new Map<string, string>([
['tab', 'tab'],
]);
const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'[data-plugin-editing]',
'input',
'textarea',
'select',
@@ -44,6 +45,10 @@ const PLUGIN_SHORTCUT_EDITABLE_SELECTOR = [
'.monaco-menu-container',
].join(', ');
export function isPluginViewEditingTarget(node: EventTarget | null): boolean {
return Boolean((node as Element | null)?.closest?.('[data-plugin-editing]'));
}
function hasEditableShortcutAncestor(node: unknown): boolean {
const closest = (node as { closest?: (selector: string) => unknown } | null)?.closest;
return typeof closest === 'function'
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('React 19 changes are staged as safe data before any host rendering', async () => {
const dom = new JSDOM('<!doctype html><div id="remote"></div>', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'Event', 'MouseEvent']) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { RemoteMutationObserver } = await import('@remote-dom/core/elements');
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const staged = createPluginRemoteTree();
const snapshots: ReturnType<typeof staged.snapshot>[] = [];
const observer = new RemoteMutationObserver({
mutate(records) { snapshots.push(staged.apply(JSON.parse(JSON.stringify(records)))); },
call() { throw new Error('Host method calls are unavailable'); },
});
const container = document.getElementById('remote')!;
observer.observe(container);
const root = createRoot(container);
function Example() {
const [count, setCount] = useState(0);
return <section className="card" style={{ display: 'grid', gap: '12px' }}><h1>Hello</h1><button type="button" onClick={() => setCount((value) => value + 1)}>Count {count}</button></section>;
}
try {
await act(async () => { root.render(<Example />); await new Promise((resolve) => setTimeout(resolve, 10)); });
const first = snapshots.at(-1)!;
const section = first.children[0]; assert.equal(section.type, 1);
if (section.type !== 1) throw new Error('Expected section');
assert.equal(section.attributes.class, 'card');
assert.match(section.attributes.style, /display:\s*grid/);
await act(async () => { container.querySelector('button')!.click(); await new Promise((resolve) => setTimeout(resolve, 10)); });
const updated = JSON.stringify(snapshots.at(-1));
assert.match(updated, /Count /); assert.match(updated, /"data":"1"/);
assert.equal(staged.isFailed(), false);
assert.throws(() => staged.apply([[3, section.id, 'onclick', 'attack()']]));
assert.deepEqual(staged.snapshot(), { children: [] });
assert.equal(staged.isFailed(), true);
assert.throws(() => staged.apply([]));
} finally {
observer.disconnect(); await act(async () => { root.unmount(); });
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
test('removed attributes do not keep checkboxes, menus or old inline styles active', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox', checked: '', style: 'color:red' }, children: [] }, 0]]);
const updated = tree.apply([[3, 'control', 'checked', null], [3, 'control', 'style', null]]);
assert.deepEqual(updated.children[0], { id: 'control', type: 1, element: 'input', attributes: { type: 'checkbox' }, children: [] });
});
test('inserting a child into an existing void element fails the view', async () => {
const { createPluginRemoteTree } = await import('./pluginRemoteTree');
const tree = createPluginRemoteTree();
tree.apply([[0, '~', { id: 'input', type: 1, element: 'input', attributes: {}, children: [] }, 0]]);
assert.throws(() => tree.apply([[0, 'input', { id: 'child', type: 3, data: 'invalid' }, 0]]), /Void view elements/);
assert.equal(tree.isFailed(), true);
assert.deepEqual(tree.snapshot(), { children: [] });
});
+41
View File
@@ -0,0 +1,41 @@
import { RemoteReceiver } from '@remote-dom/core/receivers';
import { validatePluginMutationBatch, validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
type WireNode = { id: string; type: 3 | 8; data: string } | {
id: string; type: 1; element: string; attributes: Record<string, string>; children: WireNode[];
};
type WireTree = { children: WireNode[] };
/** Data-only staging area: untrusted changes are never applied directly to the host DOM. */
export function createPluginRemoteTree() {
const receiver = new RemoteReceiver();
let current: WireTree = { children: [] };
let failed = false;
const project = (node: unknown): WireNode => {
const value = node as { id: string; type: number; data?: string; element?: string; attributes?: Record<string, string>; children?: unknown[] };
if (value.type === 3 || value.type === 8) return { id: value.id, type: value.type, data: value.data ?? '' };
return { id: value.id, type: 1, element: value.element ?? '', attributes: Object.fromEntries(Object.entries(value.attributes ?? {}).filter(([, attribute]) => attribute !== null)), children: (value.children ?? []).map(project) };
};
return {
apply(input: unknown): WireTree {
if (failed) throw new Error('Plugin view has been closed after an invalid update');
try {
validatePluginMutationBatch(input);
// Ordinary DOM attribute mutations do not specify a channel. RemoteReceiver
// defaults those to properties, so explicitly mark them as attributes.
const batch = (input as unknown[][]).map((record) => record[0] === 3 && record.length === 4 ? [...record, 2] : record);
receiver.connection.mutate(batch as unknown as Parameters<typeof receiver.connection.mutate>[0]);
const next = { children: receiver.root.children.map(project) };
validatePluginRemoteTree(next);
current = next;
return next;
} catch (error) {
failed = true;
current = { children: [] };
throw error;
}
},
snapshot(): WireTree { return current; },
isFailed(): boolean { return failed; },
};
}
+2 -133
View File
@@ -1,111 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
PluginViewLifecycleController,
consumeClosedPluginViewInstance,
reconcilePluginViewTabCatalog,
markPluginViewOpenTokensClosed,
reconcileClosedPluginView,
rememberClosedPluginViewInstance,
resolvePluginViewSnapshotSelection,
shouldReconcilePluginViewTabCatalog,
withdrawPluginViewTab,
type HostedPluginViewState,
} from './pluginViewLifecycle.ts';
import { PluginViewTabStore } from './pluginViewTabStore.ts';
function view(id: string, tabId?: string): HostedPluginViewState {
return {
id,
viewId: `view.${id}`,
scopeId: 'window:main',
retainContextWhenHidden: false,
...(tabId ? { tabId } : {}),
};
}
test('host close events clear the active renderer instance and identify its native tab', () => {
const current = view('active', 'plugin-view:publisher.plugin:view.active');
const result = reconcileClosedPluginView({
current,
retained: new Map([['retained', view('retained')]]),
instanceId: 'active',
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.closedTabId, current.tabId);
assert.equal(result.retained.size, 1);
});
test('host close events remove retained instances without dismissing another active view', () => {
const current = view('active');
const retainedTab = view('retained-closed', 'plugin-view:publisher.plugin:view.retained');
const result = reconcileClosedPluginView({
current,
retained: new Map([
['closed', retainedTab],
['kept', view('retained-kept')],
]),
instanceId: 'retained-closed',
});
assert.equal(result.current, current);
assert.equal(result.matchedCurrent, false);
assert.equal(result.matchedRetained, true);
assert.deepEqual([...result.retained.keys()], ['kept']);
assert.equal(result.closedTabId, retainedTab.tabId);
});
test('explicit native-tab close destroys active and retained instances instead of retaining them', () => {
const tabId = 'plugin-view:publisher.plugin:view.shared';
const result = withdrawPluginViewTab({
current: view('active', tabId),
retained: new Map([
['same-tab', view('retained-same', tabId)],
['other-tab', view('retained-other', 'plugin-view:publisher.plugin:view.other')],
]),
tabId,
});
assert.equal(result.current, null);
assert.equal(result.matchedCurrent, true);
assert.equal(result.matchedRetained, true);
assert.deepEqual(result.instanceIds, ['active', 'retained-same']);
assert.deepEqual([...result.retained.keys()], ['other-tab']);
});
test('an early host close tombstone is consumed when the open response arrives later', () => {
const tombstones = new Set<string>();
rememberClosedPluginViewInstance(tombstones, 'instance-early');
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), true);
assert.equal(consumeClosedPluginViewInstance(tombstones, 'instance-early'), false);
for (let index = 0; index < 300; index += 1) {
rememberClosedPluginViewInstance(tombstones, `instance-${index}`);
}
assert.equal(tombstones.size, 256);
assert.equal(tombstones.has('instance-0'), false);
});
test('explicit close marks only in-flight opens owned by the closed surface', () => {
const first = Symbol('first');
const second = Symbol('second');
const explicitlyClosed = new Set<symbol>();
const opening = new Map<string, Set<symbol>>([
['window:main\0view.first', new Set([first])],
['window:main\0view.second', new Set([second])],
]);
assert.equal(markPluginViewOpenTokensClosed(
opening,
explicitlyClosed,
'window:main\0view.first',
), 1);
assert.deepEqual([...explicitlyClosed], [first]);
assert.equal(markPluginViewOpenTokensClosed(opening, explicitlyClosed, null), 0);
});
import { reconcilePluginViewTabCatalog, resolvePluginViewSnapshotSelection, shouldReconcilePluginViewTabCatalog } from './pluginViewLifecycle';
import { PluginViewTabStore } from './pluginViewTabStore';
test('locale-only snapshot refresh keeps the owned view alive without weakening context fail-closed behavior', () => {
const previous = {
@@ -184,30 +80,3 @@ test('active-tab context refresh cannot withdraw the plugin tab that triggered i
assert.equal(store.getTabs()[0]?.title, 'Localized View');
assert.equal(activeTabId, tab.id);
});
test('lifecycle controller owns retained views, open tokens, tombstones, and teardown', () => {
const controller = new PluginViewLifecycleController<HostedPluginViewState>();
const tabId = 'plugin-view:publisher.plugin:view.shared';
const active = view('active', tabId);
const retained = view('retained');
controller.setCurrent(active);
controller.retain('window:main\0view.retained', retained);
const token = controller.beginOpen({
viewKey: 'window:main\0view.active',
tabId,
});
const closed = controller.handleTabClose(tabId);
assert.deepEqual(closed.instanceIds, [active.id]);
assert.equal(controller.shouldCloseOpen(token), true);
controller.finishOpen({ token, viewKey: 'window:main\0view.active', tabId });
assert.equal(controller.shouldCloseOpen(token), false);
const early = controller.handleHostClose('instance-before-open-response');
assert.equal(early.matchedCurrent, false);
assert.equal(early.matchedRetained, false);
assert.equal(controller.consumeHostClose('instance-before-open-response'), true);
assert.deepEqual(controller.drain().map((candidate) => candidate.id), [retained.id]);
assert.equal(controller.getCurrent(), null);
});
-246
View File
@@ -1,11 +1,3 @@
export interface HostedPluginViewState {
id: string;
viewId: string;
scopeId: string;
retainContextWhenHidden: boolean;
tabId?: string;
}
export interface PluginViewSnapshotSelection<T> {
requestViewId: string;
contextKey: string;
@@ -81,241 +73,3 @@ export function reconcilePluginViewTabCatalog({
store.refreshMetadata(entries);
return true;
}
export function reconcileClosedPluginView<T extends HostedPluginViewState>({
current,
retained,
instanceId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
instanceId: string;
}): {
current: T | null;
retained: Map<string, T>;
matchedCurrent: boolean;
matchedRetained: boolean;
closedTabId?: string;
} {
const matchedCurrent = current?.id === instanceId;
let matchedRetained = false;
let retainedTabId: string | undefined;
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.id === instanceId) {
matchedRetained = true;
retainedTabId = view.tabId;
}
else nextRetained.set(key, view);
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
matchedCurrent,
matchedRetained,
...((matchedCurrent ? current?.tabId : retainedTabId)
? { closedTabId: matchedCurrent ? current?.tabId : retainedTabId }
: {}),
};
}
export function withdrawPluginViewTab<T extends HostedPluginViewState>({
current,
retained,
tabId,
}: {
current: T | null;
retained: ReadonlyMap<string, T>;
tabId: string;
}): {
current: T | null;
retained: Map<string, T>;
instanceIds: string[];
matchedCurrent: boolean;
matchedRetained: boolean;
} {
const matchedCurrent = current?.tabId === tabId;
let matchedRetained = false;
const instanceIds: string[] = [];
if (matchedCurrent && current) instanceIds.push(current.id);
const nextRetained = new Map<string, T>();
for (const [key, view] of retained) {
if (view.tabId === tabId) {
matchedRetained = true;
instanceIds.push(view.id);
} else {
nextRetained.set(key, view);
}
}
return {
current: matchedCurrent ? null : current,
retained: nextRetained,
instanceIds,
matchedCurrent,
matchedRetained,
};
}
export function rememberClosedPluginViewInstance(
tombstones: Set<string>,
instanceId: string,
limit = 256,
): void {
if (tombstones.size >= limit) {
const oldest = tombstones.values().next().value;
if (typeof oldest === 'string') tombstones.delete(oldest);
}
tombstones.add(instanceId);
}
export function consumeClosedPluginViewInstance(tombstones: Set<string>, instanceId: string): boolean {
return tombstones.delete(instanceId);
}
export function markPluginViewOpenTokensClosed(
openingTokens: ReadonlyMap<string, ReadonlySet<symbol>>,
explicitlyClosedTokens: Set<symbol>,
ownerKey: string | null | undefined,
): number {
if (!ownerKey) return 0;
const tokens = openingTokens.get(ownerKey);
if (!tokens) return 0;
for (const token of tokens) explicitlyClosedTokens.add(token);
return tokens.size;
}
export class PluginViewLifecycleController<T extends HostedPluginViewState = HostedPluginViewState> {
private current: T | null = null;
private retained = new Map<string, T>();
private readonly closedInstanceIds = new Set<string>();
private readonly openingTokensByTab = new Map<string, Set<symbol>>();
private readonly openingTokensByView = new Map<string, Set<symbol>>();
private readonly explicitlyClosedOpenTokens = new Set<symbol>();
getCurrent(): T | null {
return this.current;
}
setCurrent(view: T | null): void {
this.current = view;
}
takeCurrent(): T | null {
const current = this.current;
this.current = null;
return current;
}
retain(key: string, view: T): void {
this.retained.set(key, view);
}
takeRetained(key: string): T | null {
const retained = this.retained.get(key) ?? null;
if (retained) this.retained.delete(key);
return retained;
}
removeRetained(key: string): void {
this.retained.delete(key);
}
removeRetainedWhere(predicate: (view: T) => boolean): T[] {
const removed: T[] = [];
for (const [key, view] of this.retained) {
if (!predicate(view)) continue;
this.retained.delete(key);
removed.push(view);
}
return removed;
}
handleHostClose(instanceId: string) {
const next = reconcileClosedPluginView({
current: this.current,
retained: this.retained,
instanceId,
});
this.current = next.current;
this.retained = next.retained;
if (!next.matchedCurrent && !next.matchedRetained) {
rememberClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
return next;
}
handleTabClose(tabId: string) {
const next = withdrawPluginViewTab({
current: this.current,
retained: this.retained,
tabId,
});
this.current = next.current;
this.retained = next.retained;
markPluginViewOpenTokensClosed(
this.openingTokensByTab,
this.explicitlyClosedOpenTokens,
tabId,
);
return next;
}
markViewClosed(viewKey: string | null | undefined): number {
return markPluginViewOpenTokensClosed(
this.openingTokensByView,
this.explicitlyClosedOpenTokens,
viewKey,
);
}
beginOpen({
viewKey,
tabId,
label,
}: {
viewKey: string;
tabId?: string;
label?: string;
}): symbol {
const token = Symbol(label ?? tabId ?? viewKey);
const viewTokens = this.openingTokensByView.get(viewKey) ?? new Set<symbol>();
viewTokens.add(token);
this.openingTokensByView.set(viewKey, viewTokens);
if (tabId) {
const tabTokens = this.openingTokensByTab.get(tabId) ?? new Set<symbol>();
tabTokens.add(token);
this.openingTokensByTab.set(tabId, tabTokens);
}
return token;
}
finishOpen({ token, viewKey, tabId }: { token: symbol; viewKey: string; tabId?: string }): void {
this.explicitlyClosedOpenTokens.delete(token);
const viewTokens = this.openingTokensByView.get(viewKey);
viewTokens?.delete(token);
if (viewTokens?.size === 0) this.openingTokensByView.delete(viewKey);
if (!tabId) return;
const tabTokens = this.openingTokensByTab.get(tabId);
tabTokens?.delete(token);
if (tabTokens?.size === 0) this.openingTokensByTab.delete(tabId);
}
shouldCloseOpen(token: symbol): boolean {
return this.explicitlyClosedOpenTokens.has(token);
}
consumeHostClose(instanceId: string): boolean {
return consumeClosedPluginViewInstance(this.closedInstanceIds, instanceId);
}
drain(): T[] {
const values = [this.current, ...this.retained.values()].filter((view): view is T => Boolean(view));
this.current = null;
this.retained.clear();
this.closedInstanceIds.clear();
this.openingTokensByTab.clear();
this.openingTokensByView.clear();
this.explicitlyClosedOpenTokens.clear();
return values;
}
}
@@ -0,0 +1,41 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { HostDrawnViewMessage, HostDrawnViewOpenRequest } from '@netcatty/plugin-contract';
type Update = Extract<HostDrawnViewMessage, { kind: 'host-ui.update' }> & { viewId: string };
import { PluginViewSessions } from './pluginViewSession';
function fixture() {
let updates: (data: Update) => void = () => {}; let closed: (data: NetcattyPluginViewClosedEvent) => void = () => {};
const opened: HostDrawnViewOpenRequest[] = []; const closes: string[] = []; const events: unknown[] = [];
const bridge = { openHostDrawnPluginView: async (request: HostDrawnViewOpenRequest) => { opened.push(request); return { instanceId: request.instanceId }; },
closeHostDrawnPluginView: async (id: string) => { closes.push(id); },
onHostDrawnPluginViewUpdate: (listener: typeof updates) => { updates = listener; return () => {}; },
onPluginViewClosed: (listener: typeof closed) => { closed = listener; return () => {}; },
sendHostDrawnPluginViewEvent: async (id: string, event: unknown) => { events.push({ id, event }); } };
return { bridge, opened, closes, events, update: (data: Update) => updates(data), close: (data: { instanceId: string }) => closed({ ...data, viewId: request.viewId, pluginId: 'com.example', reason: 'test' }) };
}
const request = { viewId: 'com.example.view', scopeId: 'window:one', context: {} };
test('a hidden view resumes its existing state and plugin instance', async () => {
const f = fixture(); const store = new PluginViewSessions();
const first = store.acquire('view', f.bridge, request); const id = f.opened[0].instanceId;
f.update({ kind: 'host-ui.update', viewId: request.viewId, instanceId: id, sequence: 1, css: '', mutations: [[0, '~', { id: 'text', type: 3, data: 'kept' }, 0]] });
first.release(); const next = store.acquire('view', f.bridge, request);
assert.equal(next, first); assert.equal(f.opened.length, 1); assert.deepEqual(next.getSnapshot().tree.children[0], { id: 'text', type: 3, data: 'kept' });
next.release(); store.dispose(); await Promise.resolve(); assert.equal(new Set(f.closes).size, 1);
});
test('host invalidation clears retained content and a reopen creates a new instance', () => {
const f = fixture(); const store = new PluginViewSessions(); const old = store.acquire('view', f.bridge, request);
f.close({ instanceId: f.opened[0].instanceId }); assert.equal(old.getSnapshot().error, true); assert.deepEqual(old.getSnapshot().tree.children, []);
const next = store.acquire('view', f.bridge, request); assert.notEqual(old, next); store.dispose();
});
test('unacknowledged input is not overwritten by an older plugin update', () => {
const f = fixture(); const store = new PluginViewSessions(); const view = store.acquire('view', f.bridge, request);
view.send({ type: 'input', nodeId: 'field', value: 'latest' }); assert.equal(view.getSnapshot().settled, false);
const base = { kind: 'host-ui.update' as const, viewId: request.viewId, instanceId: f.opened[0].instanceId, css: '', mutations: [] };
f.update({ ...base, sequence: 1, acknowledgedEvent: 0 }); assert.equal(view.getSnapshot().settled, false);
f.update({ ...base, sequence: 2, acknowledgedEvent: 1 }); assert.equal(view.getSnapshot().settled, true); store.dispose();
});
test('idle instances are evicted while active instances are never silently replaced', () => {
const f = fixture(); const store = new PluginViewSessions(1); const first = store.acquire('one', f.bridge, request);
assert.throws(() => store.acquire('two', f.bridge, request), /Too many/); first.release();
const second = store.acquire('two', f.bridge, request); assert.notEqual(second, first); assert.equal(f.closes.length, 1); store.dispose();
});
+78
View File
@@ -0,0 +1,78 @@
import { createPluginRemoteTree } from './pluginRemoteTree';
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
type Bridge = Pick<NonNullable<ReturnType<typeof import('../../infrastructure/services/netcattyBridge').netcattyBridge.get>>, 'openHostDrawnPluginView' | 'closeHostDrawnPluginView' | 'onHostDrawnPluginViewUpdate' | 'onPluginViewClosed' | 'sendHostDrawnPluginViewEvent' | 'getPluginViewResource'>;
export type PluginViewSnapshot = { tree: ReturnType<ReturnType<typeof createPluginRemoteTree>['snapshot']>; css: string; loading: boolean; error: boolean; settled: boolean };
const empty = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
/** Owns a window's bounded, retained UI instances independently of React mounts. */
export class PluginViewSessions {
private sessions = new Map<string, ReturnType<PluginViewSessions['create']>>();
constructor(private readonly capacity = 16, private readonly idleMs = 300_000) {}
private create(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
const id = crypto.randomUUID();
const tree = createPluginRemoteTree();
let state = empty(); let sequence = 1; let eventId = 0; let disposed = false; let users = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const listeners = new Set<() => void>();
const publish = (next: PluginViewSnapshot) => { state = next; for (const listener of listeners) listener(); };
const cleanup: Array<() => void> = [];
const dispose = (failed = false) => {
if (disposed) return;
disposed = true; clearTimeout(timer); cleanup.forEach((stop) => stop());
this.sessions.delete(key);
publish({ ...empty(), loading: false, error: failed });
void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
};
if (!bridge.openHostDrawnPluginView || !bridge.onHostDrawnPluginViewUpdate || !bridge.onPluginViewClosed) throw new Error('Plugin views unavailable');
cleanup.push(bridge.onHostDrawnPluginViewUpdate((update) => {
if (disposed || update.kind !== 'host-ui.update' || update.instanceId !== id || update.viewId !== request.viewId) return;
try {
if (update.sequence !== sequence++) throw new Error('Invalid plugin update order');
publish({ tree: tree.apply(update.mutations), css: validatePluginStylesheet(update.css), loading: false, error: false, settled: (update.acknowledgedEvent ?? 0) >= eventId });
} catch { dispose(true); }
}));
cleanup.push(bridge.onPluginViewClosed((event) => { if (event.instanceId === id) dispose(true); }));
void bridge.openHostDrawnPluginView({ ...request, instanceId: id }).then(() => {
if (disposed) void bridge.closeHostDrawnPluginView?.(id).catch(() => {});
}).catch(() => dispose(true));
return {
viewId: request.viewId,
scope: request.scopeId,
image: async (path: string) => {
if (disposed || !bridge.getPluginViewResource) throw new Error('Plugin image unavailable');
const result = await bridge.getPluginViewResource({ instanceId: id, path });
if (disposed) throw new Error('Plugin view closed');
return result.url;
},
get idle() { return users === 0; },
getSnapshot: () => state,
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
acquire: () => { clearTimeout(timer); users++; },
release: () => { if (--users === 0 && !disposed) timer = setTimeout(() => dispose(), this.idleMs); },
send: (event: HostDrawnViewEvent) => {
if (disposed || users === 0) return;
publish({ ...state, settled: false });
void bridge.sendHostDrawnPluginViewEvent?.(id, { ...event, eventId: ++eventId }).catch(() => dispose(true));
},
dispose,
};
}
acquire(key: string, bridge: Bridge, request: Omit<import('@netcatty/plugin-contract').HostDrawnViewOpenRequest, 'instanceId'>) {
let session = this.sessions.get(key);
if (!session) {
while (this.sessions.size >= this.capacity) {
const candidate = [...this.sessions.values()].find((item) => item.idle);
if (!candidate) throw new Error('Too many open plugin views');
candidate.dispose();
}
session = this.create(key, bridge, request); this.sessions.set(key, session);
}
session.acquire(); return session;
}
closeView(viewId: string) { for (const session of [...this.sessions.values()]) if (session.viewId === viewId) session.dispose(); }
closeScope(scope: string) { for (const session of [...this.sessions.values()]) if (session.scope === scope) session.dispose(); }
dispose() { for (const session of [...this.sessions.values()]) session.dispose(); }
}
export const pluginViewSessions = new PluginViewSessions();
@@ -3,6 +3,37 @@ import test from 'node:test';
import { createTerminalReconnectRegistry } from './terminalReconnectRegistry';
test('recovery snapshots use the current handler and disappear on unmount', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.inspect('s'), null);
const unregister = registry.register('s', () => {}, () => false,
() => ({ status: 'disconnected', waitingForInput: true, reconnecting: false }));
assert.equal(registry.inspect('s')?.waitingForInput, true);
assert.equal(registry.inspect('s')?.reconnectAllowed, false);
unregister();
assert.equal(registry.inspect('s'), null);
});
test('agent reconnect never queues an unmounted terminal', () => {
const registry = createTerminalReconnectRegistry();
assert.equal(registry.requestIfAvailable('missing'), false);
let calls = 0;
registry.register('missing', () => { calls++; });
assert.equal(calls, 0);
});
test('agent reconnect respects live eligibility and active reconnects', () => {
const registry = createTerminalReconnectRegistry();
let eligible = false;
let calls = 0;
registry.register('s', () => { calls++; registry.setActive('s', true); }, () => eligible);
assert.equal(registry.requestIfAvailable('s'), false);
eligible = true;
assert.equal(registry.requestIfAvailable('s'), true);
assert.equal(registry.requestIfAvailable('s'), false);
assert.equal(calls, 1);
});
test('a registered terminal session can be reconnected on request', () => {
const registry = createTerminalReconnectRegistry();
const requestedSessionIds: string[] = [];
+27 -1
View File
@@ -1,7 +1,10 @@
type TerminalReconnectHandler = () => void;
export type TerminalRecoverySnapshot = { status: string; waitingForInput: boolean; reconnecting: boolean; stopped?: boolean };
export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>();
const eligibility = new Map<string, () => boolean>();
const recoveryReaders = new Map<string, () => TerminalRecoverySnapshot>();
const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>();
const enterReconnectEnabledIds = new Set<string>();
@@ -28,19 +31,40 @@ export const createTerminalReconnectRegistry = () => {
return () => listeners.delete(listener);
};
const register = (sessionId: string, handler: TerminalReconnectHandler): (() => void) => {
const register = (sessionId: string, handler: TerminalReconnectHandler,
canReconnect: () => boolean = () => false, readRecovery?: () => TerminalRecoverySnapshot): (() => void) => {
handlers.set(sessionId, handler);
eligibility.set(sessionId, canReconnect);
if (readRecovery) recoveryReaders.set(sessionId, readRecovery);
else recoveryReaders.delete(sessionId);
if (pendingRequests.delete(sessionId)) {
handler();
}
return () => {
if (handlers.get(sessionId) === handler) {
handlers.delete(sessionId);
eligibility.delete(sessionId);
recoveryReaders.delete(sessionId);
setActive(sessionId, false);
}
};
};
const inspect = (sessionId: string) => {
const state = recoveryReaders.get(sessionId)?.();
if (!state) return null;
return { ...state, reconnecting: state.reconnecting || isActive(sessionId),
reconnectAllowed: !isActive(sessionId) && Boolean(eligibility.get(sessionId)?.()) };
};
// Agent requests must never survive unmount or bypass authentication/auto-retry.
const requestIfAvailable = (sessionId: string): boolean => {
const handler = handlers.get(sessionId);
if (!handler || isActive(sessionId) || !eligibility.get(sessionId)?.()) return false;
handler();
return true;
};
const request = (sessionId: string): boolean => {
if (isActive(sessionId)) return false;
const handler = handlers.get(sessionId);
@@ -66,7 +90,9 @@ export const createTerminalReconnectRegistry = () => {
return {
register,
inspect,
request,
requestIfAvailable,
requestEnterReconnect,
setEnterReconnectEnabled,
setActive,
@@ -0,0 +1,44 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('plugin tools stay in the toolbar and retain placement across catalog changes and remount', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'localStorage', 'StorageEvent', 'CustomEvent', 'Event']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { useTerminalSidePanelTabOrder, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER } = await import('./terminalSidePanelTabs');
const { STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT } = await import('../../infrastructure/config/storageKeys');
const plugin = 'plugin-view:com.example.sidebar.page' as const;
dom.window.localStorage.setItem(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT, JSON.stringify({ order: TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, placement: {} }));
let model: ReturnType<typeof useTerminalSidePanelTabOrder>;
let tools: typeof plugin[] = [];
function Fixture() { model = useTerminalSidePanelTabOrder(tools); return <div />; }
const root = createRoot(document.getElementById('root')!);
const render = async () => { await act(async () => { root.render(<Fixture />); }); };
try {
await render();
tools = [plugin]; await render();
assert.equal(model!.sidePanelTabOrder[1], plugin, 'new plugin is a visible toolbar item');
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).shown.includes(plugin));
await act(async () => { model!.setPlacement(plugin, 'collapse', [...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]); });
tools = []; await render();
assert.ok(!model!.partition(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER).collapsed.includes(plugin));
tools = [plugin]; await render();
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin));
await act(async () => { root.unmount(); });
const nextRoot = createRoot(document.getElementById('root')!);
tools = []; await act(async () => { nextRoot.render(<Fixture />); });
tools = [plugin]; await act(async () => { nextRoot.render(<Fixture />); });
assert.ok(model!.partition([...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, plugin]).collapsed.includes(plugin), 'cold catalog loading preserves preference');
await act(async () => { nextRoot.unmount(); });
} finally {
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
+40 -4
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import {
normalizeToolbarItemLayout,
@@ -13,7 +13,7 @@ import {
} from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { useToolbarItemLayout } from './useToolbarItemLayout';
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
import { isPluginSidePanelTool, type BuiltinSidePanelTool, type SidePanelTool } from '../../domain/sidePanelLayout';
export type TerminalSidePanelTabId = SidePanelTool;
@@ -101,7 +101,7 @@ function migrateSidePanelTabLayoutIfNeeded(): void {
migrateSidePanelTabLayoutIfNeeded();
export function useTerminalSidePanelTabOrder(): {
export function useTerminalSidePanelTabOrder(pluginTools: readonly SidePanelTool[] = []): {
sidePanelTabOrder: TerminalSidePanelTabId[];
setSidePanelTabOrder: (order: TerminalSidePanelTabId[]) => void;
layout: ToolbarItemLayout;
@@ -111,6 +111,23 @@ export function useTerminalSidePanelTabOrder(): {
resetLayout: () => void;
partition: ReturnType<typeof useToolbarItemLayout>['partition'];
} {
// Keep preferences for temporarily unavailable/disabled plugins, including
// across a cold start before the contribution catalog finishes loading.
const knownPluginTools = useRef<Set<string> | null>(null);
if (!knownPluginTools.current) {
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { stored = null; }
const order = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : [];
knownPluginTools.current = new Set(Array.isArray(order)
? order.filter((id): id is string => typeof id === 'string' && isPluginSidePanelTool(id)) : []);
}
for (const tool of pluginTools) if (isPluginSidePanelTool(tool)) knownPluginTools.current.add(tool);
const pluginKey = [...knownPluginTools.current].join('\0');
const defaults = useMemo<ToolbarItemLayoutDefaults>(() => {
const plugins = pluginKey ? pluginKey.split('\0') : [];
return { order: [TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER[0], ...plugins, ...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER.slice(1)] };
}, [pluginKey]);
const {
layout,
setPlacement,
@@ -121,9 +138,28 @@ export function useTerminalSidePanelTabOrder(): {
partition,
} = useToolbarItemLayout(
STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT,
TERMINAL_SIDE_PANEL_TAB_LAYOUT_DEFAULTS,
defaults,
);
// Newly installed plugins get a visible slot near the start even when an
// older saved layout already exists. Existing choices always keep their order.
const activePluginKey = pluginTools.filter(isPluginSidePanelTool).join('\0');
useEffect(() => {
const active = activePluginKey ? activePluginKey.split('\0') : [];
if (!active.length || active.some((id) => !layout.order.includes(id))) return;
let stored: unknown;
try { stored = localStorageAdapter.read(STORAGE_KEY_TERMINAL_SIDE_PANEL_TAB_LAYOUT); } catch { return; }
const storedOrder = stored && typeof stored === 'object' && !Array.isArray(stored)
? (stored as { order?: unknown }).order : null;
const saved = new Set(Array.isArray(storedOrder) ? storedOrder : []);
const fresh = active.filter((id) => !saved.has(id));
if (!fresh.length) return;
const rest = layout.order.filter((id) => !fresh.includes(id));
const index = Math.max(0, rest.indexOf('sftp') + 1);
const next = [...rest.slice(0, index), ...fresh, ...rest.slice(index)];
if (next.some((id, i) => id !== layout.order[i])) setOrder(next);
}, [activePluginKey, layout.order, setOrder]);
const sidePanelTabOrder = layout.order as TerminalSidePanelTabId[];
const setSidePanelTabOrder = useCallback(
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { JSDOM } from 'jsdom';
import type { AISession } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_SESSIONS, STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS } from '../../infrastructure/config/storageKeys';
import { useAIState } from './useAIState';
import { useAISessionsStore } from './aiSessionsStore';
import { setLatestAISessionsSnapshot } from './aiStateSnapshots';
for (const enabled of [true, false]) {
test(`appending to long history preserves the synced prefix (chat sync ${enabled})`, async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
for (const name of ['window', 'document', 'localStorage', 'CustomEvent', 'Event'] as const) {
Object.defineProperty(globalThis, name, { configurable: true, value: name === 'window' ? dom.window : dom.window[name] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
const source: AISession = {
id: 'long-chat', title: 'Long chat', agentId: 'codex', externalSessionId: 'native-thread',
scope: { type: 'global' }, createdAt: 1, updatedAt: 501,
messages: [{ id: 'system', role: 'system', content: 'Instruction', timestamp: 0 },
...Array.from({ length: 500 }, (_, index) => ({ id: `message-${index}`, role: 'user' as const, content: `Prompt ${index}`, timestamp: index + 1 }))],
};
setLatestAISessionsSnapshot([source]);
dom.window.localStorage.setItem(STORAGE_KEY_AI_SESSIONS, JSON.stringify([source]));
dom.window.localStorage.setItem(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS, JSON.stringify({ enabled }));
let ai: ReturnType<typeof useAIState>;
let sessions: readonly AISession[] = [];
function Harness() {
ai = useAIState();
sessions = useAISessionsStore().sessions;
return null;
}
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
try {
await act(async () => { root.render(React.createElement(Harness)); });
await act(async () => {
ai!.addMessageToSession(source.id, { id: 'new-reply', role: 'assistant', content: 'Continued', timestamp: 502 });
});
const updated = sessions.find(value => value.id === source.id)!;
assert.equal(updated.messages.length, enabled ? 502 : 500);
assert.equal(updated.messages[0].id, 'system');
assert.equal(updated.messages[1].id, enabled ? 'message-0' : 'message-2');
assert.equal(updated.messages.at(-1)?.id, 'new-reply');
assert.equal(updated.externalSessionId, 'native-thread');
} finally {
await act(async () => { root.unmount(); });
container.remove();
dom.window.close();
}
});
}
+4 -3
View File
@@ -1053,16 +1053,17 @@ export function useAIState() {
});
}, [debouncedPersistSessions]);
// Maximum messages per session to prevent unbounded memory growth
// Local-only retention. Synced history must match the full-history storage policy.
const MAX_MESSAGES_PER_SESSION = 500;
const addMessageToSession = useCallback((sessionId: string, message: ChatMessage) => {
const syncEnabled = localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled === true;
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
let msgs = [...s.messages, message];
// Trim oldest messages if exceeding limit (keep system messages)
if (msgs.length > MAX_MESSAGES_PER_SESSION) {
// Dropping synced prefixes makes the next cloud merge add them back.
if (!syncEnabled && msgs.length > MAX_MESSAGES_PER_SESSION) {
const systemMsgs = msgs.filter(m => m.role === 'system');
const nonSystemMsgs = msgs.filter(m => m.role !== 'system');
const dropped = nonSystemMsgs.length - (MAX_MESSAGES_PER_SESSION - systemMsgs.length);
-119
View File
@@ -1,119 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { isPluginViewOccluded } from './pluginViewOcclusion';
import type { UsePluginContributionsResult } from './usePluginContributions';
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
viewId: string;
version: string;
slot: string;
active: boolean;
context: Record<string, unknown>;
adapter: ViewAdapter;
subscribeHidden(listener: () => void): () => void;
unavailable(): void;
focused?(): void;
focusOnReveal?: boolean;
}) {
const mountRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const adapterRef = useRef(adapter);
const unavailableRef = useRef(unavailable);
const focusedRef = useRef(focused);
const revealFocusRef = useRef(focusOnReveal);
const requestFocusRef = useRef<() => void>(() => {});
revealFocusRef.current = focusOnReveal;
focusedRef.current = focused;
adapterRef.current = adapter;
unavailableRef.current = unavailable;
useEffect(() => {
const mount = mountRef.current;
if (!active || !mount) return;
let cancelled = false;
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
const instanceId = crypto.randomUUID();
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
const close = () => {
if (cancelled) return;
cancelled = true;
lifecycle.markViewClosed(viewId);
void closeView(instanceId).catch(() => {});
};
const failCurrent = () => {
if (cancelled) return;
close();
unavailableRef.current();
};
const unsubscribe = onViewClosed((event) => {
lifecycle.handleHostClose(event.instanceId);
if (event.instanceId === instanceId && !cancelled) {
close();
unavailableRef.current();
}
});
const stopOnHide = subscribeHidden(close);
const stopOnFocus = onViewFocused((event) => {
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
});
const bounds = () => {
const rect = mount.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
};
let opened = false;
let lastBounds = '';
let visible = false;
const focus = () => {
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
};
requestFocusRef.current = focus;
const updateVisibility = () => {
if (!opened || cancelled) return;
const rect = mount.getBoundingClientRect();
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
if (visible === next) return;
visible = next;
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
};
const resize = () => {
if (opened && !cancelled) {
const next = bounds();
const key = JSON.stringify(next);
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
}
updateVisibility();
};
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
observer?.observe(mount);
const mutations = new MutationObserver(resize);
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
window.addEventListener('resize', resize);
document.addEventListener('scroll', resize, true);
setLoading(true);
const opening = lifecycle.beginOpen({ viewKey: viewId });
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
await closeView(result.instanceId);
return;
}
opened = true;
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
resize();
setLoading(false);
}).catch(() => {
if (!cancelled) { close(); unavailableRef.current(); }
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
return () => {
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
};
}, [active, context, slot, subscribeHidden, version, viewId]);
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
return { mountRef, loading };
}
@@ -0,0 +1,42 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { pluginViewSessions, type PluginViewSnapshot } from './pluginViewSession';
import type { HostDrawnViewEvent } from '@netcatty/plugin-contract';
const initial = (): PluginViewSnapshot => ({ tree: { children: [] }, css: '', loading: true, error: false, settled: true });
export function useHostDrawnPluginView({ viewId, version, slot, active, context, subscribeHidden, unavailable }: {
viewId: string; version: string; slot: string; active: boolean; context: Record<string, unknown>;
subscribeHidden(listener: () => void): () => void; unavailable(): void;
}) {
const contextKey = JSON.stringify(context);
const key = JSON.stringify([viewId, version, slot, contextKey]);
const [state, setState] = useState<{ key: string; snapshot: PluginViewSnapshot }>({ key: '', snapshot: initial() });
const [hidden, setHidden] = useState(false);
const unavailableRef = useRef(unavailable); unavailableRef.current = unavailable;
const sessionRef = useRef<{ key: string; image(path: string): Promise<string>; send(event: HostDrawnViewEvent): void } | null>(null);
useEffect(() => {
if (!active) return;
const bridge = netcattyBridge.get();
if (!bridge) { unavailableRef.current(); return; }
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname, search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
try {
const session = pluginViewSessions.acquire(JSON.stringify([scopeId, key]), bridge, { viewId, scopeId, context: JSON.parse(contextKey) });
const update = () => { const snapshot = session.getSnapshot(); setState({ key, snapshot }); if (snapshot.error) unavailableRef.current(); };
const stop = session.subscribe(update); update(); setHidden(false);
sessionRef.current = { key, send: session.send, image: session.image };
const stopHidden = subscribeHidden(() => { sessionRef.current = null; setHidden(true); });
return () => { sessionRef.current = null; stopHidden(); stop(); session.release(); };
} catch { unavailableRef.current(); }
}, [active, contextKey, key, slot, subscribeHidden, viewId]);
const loadImage = useCallback((path: string) => {
const session = sessionRef.current;
return active && session?.key === key ? session.image(path) : Promise.reject(new Error('Plugin view unavailable'));
}, [active, key]);
const sendEvent = useCallback((event: HostDrawnViewEvent) => {
const session = sessionRef.current; if (active && session?.key === key) session.send(event);
}, [active, key]);
// A newly selected plugin must never receive values from the previous tree,
// even during the render before effect cleanup and the next activation.
return { ...(state.key === key ? state.snapshot : initial()), visible: active && !hidden, loadImage, sendEvent };
}
@@ -86,14 +86,8 @@ export interface UsePluginContributionsResult {
updateSetting(pluginId: string, settingId: string, value: unknown, scopeId?: string): Promise<{ restartRequired: boolean }>;
resetSetting(pluginId: string, settingId: string, scopeId?: string): Promise<{ restartRequired: boolean }>;
selectSettingPath(kind: 'file' | 'directory', title: string, defaultPath?: string): Promise<string | null>;
openView(payload: NetcattyPluginViewOpenRequest): Promise<{ instanceId: string }>;
closeView(instanceId: string): Promise<void>;
focusView(instanceId: string): Promise<void>;
setViewBounds(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
setViewVisibility(instanceId: string, visible: boolean): Promise<void>;
setEnvironment(environment: NetcattyPluginEnvironment): Promise<void>;
onViewClosed(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
onViewFocused(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
}
export function usePluginContributions(
@@ -189,32 +183,6 @@ export function usePluginContributions(
return picker(title, defaultPath);
}, [bridge]);
const openView = useCallback(async (payload: NetcattyPluginViewOpenRequest) => {
if (!bridge?.openPluginView) throw new Error('Plugin views are unavailable');
return bridge.openPluginView(payload);
}, [bridge]);
const closeView = useCallback(async (instanceId: string) => {
if (!bridge?.closePluginView) return;
await bridge.closePluginView(instanceId);
}, [bridge]);
const focusView = useCallback(async (instanceId: string) => {
await bridge?.focusPluginView?.({ instanceId });
}, [bridge]);
const setViewBounds = useCallback(async (
instanceId: string,
bounds: { x: number; y: number; width: number; height: number },
) => {
if (!bridge?.setPluginViewBounds) return;
await bridge.setPluginViewBounds(instanceId, bounds);
}, [bridge]);
const setViewVisibility = useCallback(async (instanceId: string, visible: boolean) => {
if (!bridge?.setPluginViewVisibility) return;
await bridge.setPluginViewVisibility(instanceId, visible);
}, [bridge]);
const setEnvironment = useCallback(async (environment: NetcattyPluginEnvironment) => {
if (!bridge?.setPluginEnvironment) return;
await bridge.setPluginEnvironment(environment);
@@ -223,10 +191,6 @@ export function usePluginContributions(
const onViewClosed = useCallback((callback: (event: NetcattyPluginViewClosedEvent) => void) => (
bridge?.onPluginViewClosed?.(callback) ?? (() => {})
), [bridge]);
const onViewFocused = useCallback((callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void) => (
bridge?.onPluginViewFocused?.(callback) ?? (() => {})
), [bridge]);
const currentLoadState = resolvePluginContributionLoadState({
currentQueryKey: queryKey,
loadedQueryKey: loadedSnapshot.queryKey,
@@ -245,13 +209,7 @@ export function usePluginContributions(
updateSetting,
resetSetting,
selectSettingPath,
openView,
closeView,
focusView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
onViewFocused,
};
}
+6 -4
View File
@@ -38,18 +38,20 @@ export function usePluginManager() {
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>, throwOnError = false) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
let failure: Error | null = null;
try { await action(netcattyBridge.require()); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
catch (cause) { failure = cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
finally {
try { await refresh(); } catch (cause) { setError(String(cause)); }
try { await refresh(); } catch (cause) { failure ??= cause instanceof Error ? cause : new Error(String(cause)); setError(failure.message); }
mutation.current = false;
setBusy(false);
}
if (throwOnError && failure) throw failure;
}, [refresh]);
return {
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
@@ -70,6 +72,6 @@ export function usePluginManager() {
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
await bridge.resetPluginSource(id, source.id);
}
}),
}, true),
};
}
@@ -0,0 +1,22 @@
import { useEffect, useMemo, useState } from 'react';
import { useI18n } from '../i18n/I18nProvider';
import { usePluginContributions } from './usePluginContributions';
import { resolvePluginSettingScopeSelection, usePluginSettingScopeCatalog } from './usePluginSettingScopeCatalog';
export function usePluginSettingsModel(pluginId: string) {
const { locale } = useI18n();
const catalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
useEffect(() => {
setScopeIds(current => resolvePluginSettingScopeSelection(catalog, current));
}, [catalog]);
const query = useMemo<NetcattyPluginContributionQuery>(() => ({
locale, context: { 'netcatty.surface': 'settings' }, scopeIds,
}), [locale, scopeIds]);
const contributions = usePluginContributions(query);
const plugin = contributions.snapshot.plugins.find(item => item.id === pluginId);
const settings = plugin?.settings.filter(setting => setting.visible) ?? [];
const contextualScopes = [...new Set(settings.map(setting => setting.scope)
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application'))];
return { ...contributions, plugin, settings, catalog, scopeIds, setScopeIds, contextualScopes };
}
+9 -209
View File
@@ -14,19 +14,12 @@ import {
usePluginViewTabs,
} from './pluginViewTabStore';
import {
PluginViewLifecycleController,
reconcilePluginViewTabCatalog,
resolvePluginViewSnapshotSelection,
type HostedPluginViewState,
type PluginViewSnapshotSelection,
} from './pluginViewLifecycle';
import {
canRetainPluginViewInScope,
resolvePluginRetainedViewKey,
resolvePluginViewWindowScope,
} from './pluginViewScopes';
import { pluginViewSessions } from './pluginViewSession';
import { usePluginContributions } from './usePluginContributions';
import { observePluginHostSecurityVisibility } from './pluginHostSecurityVisibility';
export const OPEN_PLUGIN_VIEW_EVENT = 'netcatty:open-plugin-view';
@@ -70,17 +63,8 @@ export function usePluginViewLifecycle({
const {
snapshot,
executeCommand,
openView,
closeView,
setViewBounds,
setViewVisibility,
setEnvironment,
onViewClosed,
} = contributions;
const [instance, setInstance] = useState<HostedPluginViewState | null>(null);
const lifecycleRef = useRef(new PluginViewLifecycleController<HostedPluginViewState>());
const closeViewRef = useRef(closeView);
const mountRef = useRef<HTMLDivElement>(null);
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
@@ -93,16 +77,6 @@ export function usePluginViewLifecycle({
requestedViewId: effectiveRequested?.viewId,
contextKey: viewQueryContextKey,
});
const activeViewId = activeView?.view.id;
const viewScopeId = typeof window === 'undefined'
? 'window:server'
: resolvePluginViewWindowScope(window.location);
const retainedViewKey = activeViewId
? resolvePluginRetainedViewKey(activeViewId, viewScopeId)
: null;
useEffect(() => { closeViewRef.current = closeView; }, [closeView]);
useEffect(() => {
if (viewContributions.loading) return;
stableActiveViewRef.current = resolvedActiveView && effectiveRequested?.viewId
@@ -114,26 +88,6 @@ export function usePluginViewLifecycle({
: null;
}, [effectiveRequested?.viewId, resolvedActiveView, viewContributions.loading, viewQueryContextKey]);
useEffect(() => onViewClosed((event) => {
const next = lifecycleRef.current.handleHostClose(event.instanceId);
if (next.matchedCurrent) {
setInstance(null);
setRequested(null);
}
if (next.closedTabId) pluginViewTabStore.close(next.closedTabId);
}), [onViewClosed]);
useEffect(() => pluginViewTabStore.onDidClose(({ tab }) => {
const next = lifecycleRef.current.handleTabClose(tab.id);
if (next.matchedCurrent) {
setInstance(null);
setRequested(null);
}
for (const instanceId of next.instanceIds) {
void closeViewRef.current(instanceId).catch(() => {});
}
}), []);
useEffect(() => {
const bindings = snapshot.plugins.flatMap((plugin) => plugin.keybindings)
.filter((binding) => binding.enabled)
@@ -182,164 +136,16 @@ export function usePluginViewLifecycle({
setRequested(null);
}, [activeView, requested]);
const hideOrClose = useCallback(async (current: HostedPluginViewState) => {
if (!current.retainContextWhenHidden) {
await closeView(current.id);
return;
}
const key = resolvePluginRetainedViewKey(current.viewId, current.scopeId);
lifecycleRef.current.retain(key, current);
try {
await setViewVisibility(current.id, false);
} catch {
lifecycleRef.current.removeRetained(key);
await closeView(current.id);
}
}, [closeView, setViewVisibility]);
useEffect(() => {
const stop = pluginViewTabStore.onDidClose(({ tab }) => pluginViewSessions.closeView(tab.viewId));
return () => { stop(); };
}, []);
const close = useCallback(async () => {
lifecycleRef.current.markViewClosed(retainedViewKey);
const current = lifecycleRef.current.takeCurrent();
setInstance(null);
setRequested(null);
if (current) await closeView(current.id);
}, [closeView, retainedViewKey]);
useEffect(() => {
const removed = lifecycleRef.current.removeRetainedWhere(
(retained) => !canRetainPluginViewInScope(retained.scopeId, viewScopeId),
);
for (const retained of removed) void closeView(retained.id).catch(() => {});
}, [closeView, viewScopeId]);
useEffect(() => {
if (!instance) return;
if (effectiveRequested?.viewId === instance.viewId
&& activeViewId === instance.viewId
&& instance.scopeId === viewScopeId) return;
lifecycleRef.current.takeCurrent();
setInstance(null);
if (canRetainPluginViewInScope(instance.scopeId, viewScopeId)) {
void hideOrClose(instance);
} else {
void closeView(instance.id).catch(() => {});
}
}, [activeViewId, closeView, effectiveRequested?.viewId, hideOrClose, instance, viewScopeId]);
useEffect(() => {
if (!activeViewId || !retainedViewKey || !mountRef.current || instance) return;
let cancelled = false;
const openingTabId = activePluginTab?.id;
const openingToken = lifecycleRef.current.beginOpen({
viewKey: retainedViewKey,
tabId: openingTabId,
label: activePluginTab?.id ?? activeViewId,
});
const bounds = mountRef.current.getBoundingClientRect();
const nextBounds = {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
};
void (async () => {
let opened = lifecycleRef.current.takeRetained(retainedViewKey);
if (opened) {
try {
await setViewBounds(opened.id, nextBounds);
await setViewVisibility(opened.id, true);
} catch {
try { await closeView(opened.id); } catch { /* best-effort cleanup after restore failure */ }
opened = null;
}
}
if (!opened) {
const result = await openView({
viewId: activeViewId,
scopeId: viewScopeId,
bounds: nextBounds,
context: effectiveRequested?.context,
});
opened = {
id: result.instanceId,
viewId: activeViewId,
scopeId: viewScopeId,
retainContextWhenHidden: activeView.view.retainContextWhenHidden === true,
...(activePluginTab ? { tabId: activePluginTab.id } : {}),
};
}
if (lifecycleRef.current.consumeHostClose(opened.id)) {
throw new Error('Plugin view closed while its open response was in flight');
}
if (lifecycleRef.current.shouldCloseOpen(openingToken)) {
await closeView(opened.id);
return;
}
if (cancelled) {
await hideOrClose(opened);
return;
}
lifecycleRef.current.setCurrent(opened);
setInstance(opened);
})().catch(() => {
if (cancelled) return;
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
else setRequested(null);
}).finally(() => {
lifecycleRef.current.finishOpen({
token: openingToken,
viewKey: retainedViewKey,
tabId: openingTabId,
});
});
return () => { cancelled = true; };
}, [
activePluginTab,
activeView?.view.retainContextWhenHidden,
activeViewId,
closeView,
effectiveRequested?.context,
hideOrClose,
instance,
openView,
retainedViewKey,
setViewBounds,
setViewVisibility,
viewScopeId,
]);
useEffect(() => {
if (!instance || !mountRef.current) return;
let disposed = false;
const stop = observePluginHostSecurityVisibility(mountRef.current, (visible) => {
void setViewVisibility(instance.id, visible).catch(() => {
if (!disposed) void closeView(instance.id).catch(() => {});
});
});
return () => { disposed = true; stop(); };
}, [closeView, instance, setViewVisibility]);
useEffect(() => {
if (!instance || !mountRef.current) return;
const update = () => {
const bounds = mountRef.current?.getBoundingClientRect();
if (!bounds) return;
void setViewBounds(instance.id, {
x: Math.round(bounds.x),
y: Math.round(bounds.y),
width: Math.max(1, Math.round(bounds.width)),
height: Math.max(1, Math.round(bounds.height)),
});
};
const observer = new ResizeObserver(update);
observer.observe(mountRef.current);
window.addEventListener('resize', update);
update();
return () => {
observer.disconnect();
window.removeEventListener('resize', update);
};
}, [instance, setViewBounds]);
if (activeView) pluginViewSessions.closeView(activeView.view.id);
if (activePluginTab) pluginViewTabStore.close(activePluginTab.id);
}, [activePluginTab, activeView]);
useEffect(() => {
if (!contributions.available) return;
@@ -376,16 +182,10 @@ export function usePluginViewLifecycle({
};
}, [contributions.available, locale, setEnvironment, suppliedThemeTokens, theme]);
useEffect(() => () => {
const views = lifecycleRef.current.drain();
const ids = new Set(views.map((view) => view.id));
for (const id of ids) void closeViewRef.current(id);
}, []);
return {
activeView,
close,
effectiveRequested,
mountRef,
viewQueryContext,
};
}
@@ -63,6 +63,9 @@ export function useToolbarItemLayout(
const [layout, setLayout] = useState<ToolbarItemLayout>(() => readLayout(storageKey, defaults));
useEffect(() => {
// Defaults can include dynamically discovered items; re-read without writing
// so temporary catalog changes never erase stored toolbar preferences.
setLayout(readLayout(storageKey, defaults));
const sync = (event: Event) => {
if (event instanceof StorageEvent && event.key !== storageKey) return;
if (event instanceof CustomEvent && event.detail?.key !== storageKey) return;
+4
View File
@@ -55,6 +55,8 @@ export interface UseVaultAgentBridgeInput {
openHost?: VaultAgentApiDeps['openHost'];
findExistingSessionForHost?: VaultAgentApiDeps['findExistingSessionForHost'];
closeSession?: VaultAgentApiDeps['closeSession'];
reconnectSession?: VaultAgentApiDeps['reconnectSession'];
inspectSessionRecovery?: VaultAgentApiDeps['inspectSessionRecovery'];
getScriptSessionMeta?: (sessionId: string) => Pick<TerminalSession, 'status' | 'customName' | 'hostLabel' | 'hostname' | 'username'> | undefined;
}
@@ -273,6 +275,8 @@ export function useVaultAgentBridge(input: UseVaultAgentBridgeInput): void {
? (host, isExternalMcpCall, options) => current.openHost!(host, isExternalMcpCall, options)
: undefined,
findExistingSessionForHost: current.findExistingSessionForHost,
reconnectSession: current.reconnectSession,
inspectSessionRecovery: current.inspectSessionRecovery,
closeSession: current.closeSession
? (sessionId) => current.closeSession!(sessionId)
: undefined,
+7 -7
View File
@@ -147,8 +147,8 @@ test('cold plugin host reaches the local fixture through the actual main provide
await service.manager.initialize();
const staging = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
const sample = path.join(rootPath, 'examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(staging, archive);
service.database.setPermissionMode('com.netcatty.assistant', 'context.selected.read', 'ask');
@@ -161,13 +161,13 @@ test('cold plugin host reaches the local fixture through the actual main provide
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
}
};
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: {
executeCommand: async (command: string, args: unknown) => structuredClone(await service!.contributionService.executeCommand(command, structuredClone(args))),
onDidReceiveMessage: () => ({ dispose() {} }),
}, views: { onDidReceiveMessage: () => ({ dispose() {} }) }
} });
diagnosticPhase = 'initial-selection';
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
await waitFor(() => document.getElementById('plugin-ai-note') !== null);
const noteChoice = document.getElementById('plugin-ai-note') as HTMLSelectElement;
assert.equal(noteChoice.options.length, 2);
@@ -209,7 +209,7 @@ test('cold plugin host reaches the local fixture through the actual main provide
console.error = originalConsoleError;
// JSDOM.close does not deliver the browser pagehide lifecycle. Invalidate
// the sample's pending callbacks before teardown closes its document.
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
if (panel) { panel.window.disposePanel(); panel.window.close(); }
await act(async () => root.unmount());
await service?.manager.shutdown(); aiBridge.cleanup();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
+8 -8
View File
@@ -90,8 +90,8 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(staging, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli'); const archive = path.join(directory, 'global-agent.ncpkg'); await buildPluginPackage(staging, archive);
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAgentHost sessions={[
@@ -104,14 +104,14 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
} };
let viewOpen = true;
const mountPanel = async () => {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: { executeCommand: async (name: string, params: unknown) => {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'view.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'pluginContext', { value: { commands: { executeCommand: async (name: string, params: unknown) => {
// PluginViewHost.close removes sender authority before WebContents
// closes. A pagehide RPC cannot be relied on to reach this relay.
if (!viewOpen) throw Error('Untrusted plugin view sender');
const result = structuredClone(await service!.contributionService.executeCommand(name, structuredClone(params))); pluginReplies.push(result); return result;
}, onDidReceiveMessage: () => ({ dispose() {} }), onDidChangeEnvironment: () => ({ dispose() {} }) } });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
} }, views: { onDidReceiveMessage: () => ({ dispose() {} }) } } });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8').replace('export function mount', 'function mount') + '\nwindow.disposePanel = mount(document.body, window.pluginContext, "test", "test");'));
};
await mountPanel();
const element = (id: string) => panel!.window.document.getElementById(id)!;
@@ -135,7 +135,7 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
const beforeReopen = counts.requests;
viewOpen = false;
panel!.window.dispatchEvent(new panel!.window.Event('pagehide')); panel!.window.close();
panel!.window.disposePanel(); panel!.window.close();
viewOpen = true; await mountPanel();
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
(element('conversation') as HTMLSelectElement).value = reference.conversationRef;
@@ -182,7 +182,7 @@ test('installed global Chat UI reuses actual Catty runtime, configured HTTP tran
assert.deepEqual(errors, []);
} finally {
console.error = originalError;
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
if (panel) { panel.window.disposePanel(); panel.window.close(); }
await act(async () => root.unmount()); await service?.manager.shutdown();
mcp.setPermissionMode('confirm'); mcp.cleanup(); aiBridge.cleanup(); privacy.configure({ enabled: false }); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); dom.window.close();
+21 -1
View File
@@ -5,6 +5,27 @@ import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
// Test-only observer preserves the security-surface visibility assertions.
function observePluginHostSecurityVisibility(mount: HTMLElement, publish: (visible: boolean) => void): () => void {
const document = mount.ownerDocument;
let visible = true;
const update = () => {
const blocked = [...document.querySelectorAll('[data-host-security-surface]')].some((surface) => {
const style = document.defaultView!.getComputedStyle(surface);
return style.display !== 'none' && style.visibility !== 'hidden'
&& !surface.closest('[hidden], [inert], [aria-hidden="true"]');
});
if (visible === !blocked) return;
visible = !blocked;
publish(visible);
};
const observer = new document.defaultView!.MutationObserver(update);
observer.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'aria-hidden', 'data-host-security-surface'] });
update();
return () => observer.disconnect();
}
test('registered Ask uses one in-app dialog; policy controls apply Allow and Deny with no hidden prompt', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
@@ -25,7 +46,6 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginPermissionHost } = await import('./plugins/PluginPermissionHost');
const { PluginPermissionPanel } = await import('./settings/tabs/PluginPermissionPanel');
const { observePluginHostSecurityVisibility } = await import('../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../electron/plugins/permissionDecisionHost.cjs');
const { PluginPermissionEngine } = require('../electron/plugins/permissionEngine.cjs');
const { PluginHostRpcRegistry } = require('../electron/plugins/hostRpcRegistry.cjs');
@@ -69,8 +69,8 @@ test('registered selected-summary Ask is one host chooser; Allow still selects a
await service.manager.initialize();
const stage = path.join(fixtureRoot, 'package'); fs.mkdirSync(path.join(stage, 'dist'), { recursive: true });
const sample = path.resolve('examples/plugins/assistant');
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(stage, 'dist/index.js'));
for (const file of ['netcatty.plugin.json', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(stage, file));
fs.copyFileSync(path.join(sample, 'dist/host.js'), path.join(stage, 'dist/host.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
const archive = path.join(fixtureRoot, 'assistant.ncpkg'); await buildPluginPackage(stage, archive); await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionHost /><PluginAIContextHost sessions={[]} workspaces={[]} /></I18nProvider>));
+21 -35
View File
@@ -14,11 +14,12 @@ test('terminal extension pages use actual split panes, focus and session ownersh
install('PointerEvent', dom.window.MouseEvent);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
let rectX = 200;
const rectX = 200;
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
const { default: React, act, useCallback, useRef, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { TooltipProvider } = await import('./ui/tooltip');
const { activeTabStore } = await import('../application/state/activeTabStore');
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
@@ -30,22 +31,17 @@ test('terminal extension pages use actual split panes, focus and session ownersh
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
let selectedSession = 'synthetic-session-a';
const getSession = () => selectedSession;
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', visible: true };
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
const visible = new Map<string, boolean>();
const changed = new Set<() => void>();
const opens: NetcattyPluginViewOpenRequest[] = [];
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
onPluginViewClosed() { return () => {}; },
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); },
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
onPluginViewClosed() { return () => {}; }, onHostDrawnPluginViewUpdate() { return () => {}; },
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closeHostDrawnPluginView() {},
} });
let split!: () => void;
function Fixture() {
@@ -75,54 +71,44 @@ test('terminal extension pages use actual split panes, focus and session ownersh
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
if (!page || !target) return null;
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} showHeader={panes.length === 1} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
</PersistentSidePanelPortal>;
})}</>;
}
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
try {
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
await act(async () => { root.render(<I18nProvider locale="en"><TooltipProvider><Fixture /></TooltipProvider></I18nProvider>); await settle(); });
assert.equal(document.querySelector('button[aria-label="Extension pages"]'), null, 'pages do not require opening a grouped menu');
assert.ok(document.querySelector('button[aria-label="Second page — Synthetic plugin"]'), 'each contributed page has a direct toolbar button');
const firstButton = document.querySelector<HTMLButtonElement>('button[aria-label="First page — Synthetic plugin"]')!;
await act(async () => { firstButton.click(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 1);
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
await act(async () => { split(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
assert.equal(document.querySelectorAll('[data-plugin-terminal-sidebar] header').length, 0, 'split pane headers already identify both pages');
assert.equal(opens.length, 2);
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
await act(async () => { document.querySelector<HTMLElement>(`[data-pane-tool="plugin-view:${a.id}"] [data-plugin-view-slot]`)!.dispatchEvent(new dom.window.FocusEvent('focusin', { bubbles: true })); await settle(); });
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
focus.length = 0;
await act(async () => { dialog.remove(); await settle(); });
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
const old = opens.map((item) => item.instanceId!);
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession }); await settle(); });
assert.equal(opens.length, 4);
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
const last = opens.at(-1)!.instanceId!;
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
const localPage = document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!;
await act(async () => { localPage.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+14 -37
View File
@@ -21,24 +21,19 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
const { activeTabStore } = await import('../application/state/activeTabStore');
const { requestOpenPluginView } = await import('../application/state/usePluginViewLifecycle');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', visible: true };
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', visible: true };
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] };
let changed: (() => void) | undefined;
const listeners = new Set<(event: NetcattyPluginViewClosedEvent) => void>();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], visibility: boolean[] = [];
let defer: ((value: { instanceId: string }) => void) | undefined;
let deferOpen = false;
let delayLayout = false;
const rejectOldLayout: Array<(error: Error) => void> = [];
const layoutResult = () => delayLayout ? new Promise<void>((_resolve, reject) => { rejectOldLayout.push(reject); }) : Promise.resolve();
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [];
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async getPluginContributions() { return snapshot; },
onPluginContributionsChanged(fn: () => void) { changed = fn; return () => { changed = undefined; }; },
onPluginViewClosed(fn: (event: NetcattyPluginViewClosedEvent) => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return deferOpen ? new Promise<{ instanceId: string }>((resolve) => { defer = resolve; }) : { instanceId: payload.instanceId! }; },
async closePluginView(id: string) { closes.push(id); }, async setPluginViewBounds() { await layoutResult(); },
async setPluginViewVisibility(_id: string, visible: boolean) { visibility.push(visible); await layoutResult(); },
onHostDrawnPluginViewUpdate() { return () => {}; },
async openHostDrawnPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
async closeHostDrawnPluginView(id: string) { closes.push(id); },
} });
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
function Fixture() {
@@ -58,44 +53,26 @@ test('Vault navigation mounts a real content slot, yields to host dialogs, and w
assert.ok(document.querySelector('[data-section="vault-main"] [data-plugin-view-slot="vault-page"]'));
assert.equal(opens.length, 1);
assert.deepEqual(opens[0].context, { 'netcatty.surface': 'vault-page' }, 'no Vault/session data accompanies navigation');
assert.equal(visibility.at(-1), true);
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
await act(async () => { document.body.append(dialog); await settle(); });
assert.equal(visibility.at(-1), false, 'native content yields to host security/settings dialogs');
await act(async () => { dialog.remove(); await settle(); });
assert.equal(visibility.at(-1), true);
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); assert.ok(closes.includes(opens[0].instanceId!)); await settle(); });
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); await settle(); });
assert.equal(closes.length, 0, 'hiding keeps the logical view available');
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
assert.equal(opens.length, 2);
assert.equal(opens.length, 1, 'returning to Vault reuses the same plugin state');
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
await act(async () => { await settle(); });
assert.ok(closes.includes(opens[1].instanceId!));
// The main process owns immediate invalidation on disable/uninstall.
await act(async () => { for (const listener of listeners) listener({ instanceId: opens[0].instanceId!, viewId: page.id, pluginId: 'com.example.page', reason: 'plugin-disabled' }); });
assert.ok(closes.includes(opens[0].instanceId!));
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
assert.equal(document.activeElement?.textContent, 'Hosts');
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
deferOpen = true;
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
assert.equal(opens.length, 3);
assert.equal(opens.length, 2);
await act(async () => { selectSection('hosts'); await settle(); });
assert.ok(closes.includes(opens[2].instanceId!), 'cancel reserved instance before its open response');
await act(async () => { defer!({ instanceId: opens[2].instanceId! }); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]'), null, 'late open cannot restore withdrawn page');
assert.ok(closes.filter((id) => id === opens[2].instanceId).length >= 2, 'late native response is also closed');
deferOpen = false;
const second = { ...page, id: 'com.example.page.second', title: 'Second page' };
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [page, second] }] }; changed?.(); await settle(); });
delayLayout = true;
await clickPage();
assert.ok(rejectOldLayout.length >= 2, 'old page has both bounds and visibility IPC in flight');
await act(async () => { selectSection('hosts'); await settle(); });
delayLayout = false;
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!.click(); await settle(); });
await act(async () => { for (const reject of rejectOldLayout) reject(new Error('old page layout failed')); await settle(); });
assert.equal(document.querySelector('[data-plugin-vault-page]')?.getAttribute('aria-label'), 'Second page', 'old bounds/visibility failures cannot withdraw the newly selected page');
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
} finally {
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
await act(async () => { root.unmount(); (await import('../application/state/pluginViewSession')).pluginViewSessions.dispose(); await settle(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+27 -2
View File
@@ -155,6 +155,7 @@ import { createTerminalOutputHistoryPreview } from "./terminal/runtime/terminalO
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import {
createTerminalSessionStarters,
isAuthFailureMessage,
type PendingAuth,
type TerminalSessionDataMeta,
} from "./terminal/runtime/createTerminalSessionStarters";
@@ -167,7 +168,7 @@ import {
} from "./terminal/runtime/createXTermRuntime";
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { isTerminalReadyForCommandInjection, registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import {
isIdleShellReadyForCommandInjection,
isShellPromptReadyForInsertion,
@@ -206,7 +207,7 @@ import { useTerminalFilePaste } from "./terminal/hooks/useTerminalFilePaste";
import { getRememberedYmodemSendDefaultPath, rememberYmodemSendFilePath } from "../application/state/ymodemFileMemory";
import { TerminalAutocomplete } from "./terminal/TerminalAutocomplete";
import { resolveTerminalAutocompleteSettings } from "./terminal/autocomplete/terminalAutocompleteSettings";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction } from "./terminal/osc7Setup";
import { buildOsc7SetupExecCommand, runOsc7SetupAction, shouldOfferOsc7SetupAction, waitForOsc7SessionOutput } from "./terminal/osc7Setup";
import {
getRemoteClipboardImageUploadErrorMessageKey,
type RemoteClipboardImageUploadResult,
@@ -602,6 +603,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const reconnectWakeInvalidateModeRef = useRef<"dispose" | "keep">("dispose");
const reconnectWakeTokenRef = useRef<symbol | null>(null);
const manualReconnectRequestRef = useRef<() => void>(() => {});
const agentRecoveryStoppedRef = useRef(false);
// Once a pane reconnects, every later SSH attempt in it must dial a fresh
// connection: reusing a live/idle pooled transport multiplexes onto the old
// login, so remote supplementary-group changes (e.g. `usermod -aG`) are not
@@ -1390,6 +1392,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
setupOsc7Tracking: terminalBackend.setupOsc7Tracking,
writeToSession: terminalBackend.writeToSession,
writeLocalTerminalData,
isTerminalReadyForCommandInjection: () => isTerminalReadyForCommandInjection(sessionId),
waitForSessionOutput: (id, predicate, timeoutMs) =>
waitForOsc7SessionOutput(terminalBackend.onSessionData, id, predicate, timeoutMs),
}).then((result) => {
handleOsc7SetupOpenChange(false);
if (result.success) {
@@ -1413,6 +1418,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
terminalCwdTracker,
terminalBackend.setupOsc7Tracking,
terminalBackend.writeToSession,
terminalBackend.onSessionData,
writeLocalTerminalData,
]);
@@ -3780,6 +3786,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
]);
const handleCancelConnect = () => {
agentRecoveryStoppedRef.current = true;
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
@@ -3813,6 +3820,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const handleDisconnect = () => {
if (attachExistingSession) return;
if (statusRef.current === "disconnected") return;
agentRecoveryStoppedRef.current = true;
if (pendingHostKeyRequestId) {
void terminalBackend.respondHostKeyVerification(pendingHostKeyRequestId, false);
}
@@ -4145,14 +4153,31 @@ const TerminalComponent: React.FC<TerminalProps> = ({
startReconnectRef.current = startReconnect;
const handleRetry = () => {
agentRecoveryStoppedRef.current = false;
startReconnect("manual");
};
manualReconnectRequestRef.current = handleRetry;
const canAgentReconnectRef = useRef<() => boolean>(() => false);
canAgentReconnectRef.current = () => statusRef.current === 'disconnected'
&& !agentRecoveryStoppedRef.current && !isAuthFailureMessage(error || '')
&& !auth.needsAuth && !needsHostKeyVerification && !isConnectionAwaitingUserInput
&& !autoReconnectLoopActiveRef.current && !reconnectWakeInFlightRef.current
&& reconnectPreparationTokenRef.current === null;
const readAgentRecoveryRef = useRef(() => ({ status: String(statusRef.current), waitingForInput: false, reconnecting: false, stopped: false }));
readAgentRecoveryRef.current = () => ({
status: statusRef.current,
waitingForInput: Boolean(auth.needsAuth || needsHostKeyVerification || isConnectionAwaitingUserInput || isAuthFailureMessage(error || '')),
stopped: agentRecoveryStoppedRef.current,
reconnecting: autoReconnectLoopActiveRef.current || reconnectWakeInFlightRef.current
|| reconnectPreparationTokenRef.current !== null,
});
useEffect(() => {
if (attachExistingSession) return undefined;
return terminalReconnectRegistry.register(
sessionId,
() => manualReconnectRequestRef.current(),
() => canAgentReconnectRef.current(),
() => readAgentRecoveryRef.current(),
);
}, [attachExistingSession, sessionId]);
+17 -5
View File
@@ -7,9 +7,12 @@ import {
} from '../../application/state/usePluginViewLifecycle';
import { Button } from '../ui/button';
import { PluginContributionIcon } from './PluginContributionIcon';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
export { requestOpenPluginView };
const subscribeNoop = () => () => {};
const DEFAULT_KEYBINDING_CONTEXT = Object.freeze({ 'netcatty.surface': 'keybinding' });
export function PluginContributionHost({
@@ -28,7 +31,7 @@ export function PluginContributionHost({
activeView,
close,
effectiveRequested,
mountRef,
viewQueryContext,
} = usePluginViewLifecycle({
locale,
theme,
@@ -38,18 +41,24 @@ export function PluginContributionHost({
if (!effectiveRequested || !activeView) return null;
const location = activeView.view.location;
const content = <PluginHostDrawnPage pluginId={activeView.plugin.id} viewId={activeView.view.id} version={activeView.plugin.version} slot={`view:${activeView.view.id}`} active context={viewQueryContext}
subscribeHidden={subscribeNoop} unavailable={() => void close()} loadingLabel={t('plugins.vault.loading')} />;
const containerClass = location === 'aside'
? 'absolute inset-y-0 right-0 z-40 w-[420px] border-l border-border bg-background shadow-2xl'
: location === 'tab'
? 'absolute inset-0 z-40 flex p-2 pt-0'
: location === 'panel'
? 'absolute inset-x-0 bottom-0 z-40 h-[42%] border-t border-border bg-background shadow-2xl'
: location === 'modal'
? 'fixed left-1/2 top-1/2 z-50 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
? 'fixed left-1/2 top-1/2 z-40 h-[70vh] w-[min(800px,85vw)] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border bg-background shadow-2xl'
: 'absolute inset-0 z-40 bg-background';
if (location === 'tab') {
return (
<section className={`${containerClass} flex flex-col`} role="region" aria-label={activeView.view.title}>
<div ref={mountRef} className="min-h-0 flex-1" />
<section className={containerClass} role="region" aria-label={activeView.view.title}>
<div className="flex min-h-0 min-w-0 flex-1 overflow-hidden rounded-xl border border-border/60 bg-background shadow-sm">
{content}
</div>
</section>
);
}
@@ -58,6 +67,7 @@ export function PluginContributionHost({
<section
className={`${containerClass} flex flex-col`}
role={location === 'modal' ? 'dialog' : 'region'}
data-plugin-dialog={location === 'modal' ? 'true' : undefined}
aria-modal={location === 'modal' ? true : undefined}
aria-label={activeView.view.title}
>
@@ -80,7 +90,9 @@ export function PluginContributionHost({
<X size={14} />
</Button>
</header>
<div ref={mountRef} className="min-h-0 flex-1" />
<div className={location === 'modal' ? 'flex min-h-0 flex-1 p-3' : 'flex min-h-0 flex-1'}>
{content}
</div>
</section>
);
}
@@ -0,0 +1,15 @@
import { useHostDrawnPluginView } from '../../application/state/useHostDrawnPluginView';
import { PluginSettingsEntry } from './PluginSettingsEntry';
import { PluginRemoteSurface } from './PluginRemoteSurface';
export function PluginHostDrawnPage(props: Parameters<typeof useHostDrawnPluginView>[0] & { loadingLabel: string; pluginId: string }) {
const view = useHostDrawnPluginView(props);
if (!view.visible) return null;
if (view.loading) return <p data-plugin-view-slot={props.slot.split(':')[0]} role="status" className="p-4 text-sm text-muted-foreground">{props.loadingLabel}</p>;
return <div className="relative isolate h-full min-h-0 w-full flex-1" data-plugin-view-slot={props.slot.split(':')[0]}>
<PluginRemoteSurface key={`${props.viewId}:${props.version}:${props.slot}`} tree={view.tree} css={view.css} settled={view.settled} loadImage={view.loadImage} onEvent={view.sendEvent} />
<div className="absolute right-3 top-3 z-10" data-plugin-settings-entry="">
<PluginSettingsEntry key={`${props.pluginId}:${props.version}`} pluginId={props.pluginId} />
</div>
</div>;
}
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('plugin surface forwards its own input and contains malformed updates and render errors', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div><input id="host" value="private">', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'Event', 'MouseEvent', 'ShadowRoot']) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window] });
}
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, writable: true, value: true });
let captured: ShadowRoot | undefined;
const nativeAttach = dom.window.Element.prototype.attachShadow;
dom.window.Element.prototype.attachShadow = function (options) { captured = nativeAttach.call(this, options) as unknown as ShadowRoot; return captured as unknown as globalThis.ShadowRoot; };
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginRemoteSurface } = await import('./PluginRemoteSurface');
const events: unknown[] = [];
const tree = { children: [{ id: '1', type: 1, element: 'div', attributes: { class: 'card', style: 'display:grid;gap:8px' }, children: [
{ id: '2', type: 1, element: 'button', attributes: { type: 'button', 'data-netcatty-remote-node': 'forged' }, children: [{ id: '3', type: 3, data: '<b>Hello</b>' }] },
{ id: '4', type: 1, element: 'input', attributes: { type: 'text', value: '' }, children: [] },
] }] };
const root = createRoot(document.getElementById('root')!);
try {
await act(async () => { root.render(<PluginRemoteSurface tree={tree} css=".card{color:red}" onEvent={(event) => events.push(event)} />); });
assert.ok(captured); assert.equal(document.querySelector('[data-plugin-remote-surface]')?.querySelector('button'), null);
assert.equal(captured!.querySelector('button')?.textContent, '<b>Hello</b>');
assert.equal(captured!.querySelector('button')?.getAttribute('data-netcatty-remote-node'), '2');
await act(async () => { captured!.querySelector('button')!.click(); });
assert.deepEqual(events, [{ nodeId: '2', type: 'click' }]);
const input = captured!.querySelector('input')!; input.value = 'typed';
await act(async () => { input.dispatchEvent(new dom.window.Event('input', { bubbles: true })); });
assert.deepEqual(events.at(-1), { nodeId: '4', type: 'input', value: 'typed' });
await act(async () => { document.getElementById('host')!.dispatchEvent(new dom.window.Event('input', { bubbles: true })); });
assert.equal(events.length, 2);
await act(async () => { root.render(<PluginRemoteSurface tree={tree} css="@import 'https://bad.example/x';" onEvent={() => {}} />); });
assert.equal(document.querySelector('[role=alert]')?.textContent, 'Unable to display plugin view.');
let clicks = 0;
const renderView = async (nextTree: unknown) => act(async () => root.render(<>
<PluginRemoteSurface key="view" tree={nextTree} css="" onEvent={() => {}} />
<button id="canary" onClick={() => clicks++}>Host canary</button>
<PluginRemoteSurface key="sibling" tree={tree} css="" onEvent={() => {}} />
</>));
await renderView(tree);
await renderView({ children: [{ id: 'bad', type: 1, element: 'input', attributes: {}, children: [{ id: 'text', type: 3, data: 'invalid' }] }] });
assert.equal(document.querySelectorAll('[data-plugin-remote-surface]').length, 1);
assert.ok(document.querySelector('[role=alert]'));
await act(async () => document.getElementById('canary')!.click());
assert.equal(clicks, 1);
// Validated data can still hit a React rendering constraint. Contain that
// descendant throw too, independently of the policy rejection above.
const originalError = console.error;
try {
console.error = () => {};
await renderView({ children: [{ id: 'bad', type: 1, element: 'textarea', attributes: {}, children: [
{ id: 'a', type: 3, data: 'one' }, { id: 'b', type: 3, data: 'two' },
] }] });
} finally { console.error = originalError; }
assert.equal(document.querySelectorAll('[data-plugin-remote-surface]').length, 1);
assert.ok(document.querySelector('[role=alert]'));
await act(async () => document.getElementById('canary')!.click());
assert.equal(clicks, 2);
} finally {
await act(async () => { root.unmount(); });
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT'); dom.window.close();
}
});
test('closed-shadow editing keeps app close shortcuts in Vault, sidebar, tabs and popovers', async () => {
const dom = new JSDOM('<!doctype html><div id="root"></div>', { url: 'http://localhost', pretendToBeVisual: true });
const saved = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, navigator: dom.window.navigator,
HTMLElement: dom.window.HTMLElement, Element: dom.window.Element, Node: dom.window.Node, IS_REACT_ACT_ENVIRONMENT: true })) {
saved.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
dom.window.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 20, y: 20, width: 240, height: 30, top: 20, left: 20, right: 260, bottom: 50, toJSON() {} });
Object.assign(dom.window, { ResizeObserver: class { observe() {} disconnect() {} } });
const shadows: ShadowRoot[] = [];
const nativeAttach = dom.window.Element.prototype.attachShadow;
dom.window.Element.prototype.attachShadow = function (options) {
const shadow = nativeAttach.call(this, options); shadows.push(shadow as unknown as ShadowRoot); return shadow;
};
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginRemoteSurface } = await import('./PluginRemoteSurface');
const { handleGlobalHotkeyKeyDownImpl } = await import('../../application/app/AppHandlers');
const { isPluginViewEditingTarget, isPluginShortcutEditableEvent } = await import('../../application/state/pluginKeybindings');
const { matchesKeyBinding } = await import('../../domain/models');
const { DEFAULT_KEY_BINDINGS } = await import('../../domain/models/keyBindings');
const actions: string[] = [];
const capture = (event: KeyboardEvent) => handleGlobalHotkeyKeyDownImpl(() => ({
HOTKEY_DEBUG: false, closeTabKeyStr: 'Ctrl + W', hotkeyScheme: 'pc', keyBindings: DEFAULT_KEY_BINDINGS, matchesKeyBinding,
executeHotkeyAction: (action: string) => actions.push(action),
}), event);
dom.window.addEventListener('keydown', capture as unknown as EventListener, true);
const control = (id: string, element: string, attributes = {}) => ({ id, type: 1, element, attributes, children: [] });
const tree = { children: [control('input', 'input'), control('textarea', 'textarea'), control('anchor', 'button', { id: 'anchor' }),
{ ...control('popover', 'div', { 'data-plugin-popover': 'true', 'data-plugin-anchor': 'anchor', open: '' }), children: [control('pop-input', 'input'), control('pop-textarea', 'textarea')] },
] };
const root = createRoot(document.getElementById('root')!);
try {
for (const slot of ['vault-page', 'terminal-sidebar', 'plugin-tab']) {
shadows.length = 0;
await act(async () => root.render(<div key={slot} data-plugin-view-slot={slot}><PluginRemoteSurface tree={tree} css="" onEvent={() => {}} /></div>));
assert.equal(shadows.length, 2, 'includes the portaled popover surface');
for (const shadow of shadows) {
for (const element of shadow.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>('input,textarea')) {
await act(async () => element.focus());
// Native menu IPC sees this same retargeted document.activeElement.
assert.equal(isPluginViewEditingTarget(document.activeElement), true, `${slot}: ${element.tagName}`);
let editable = false;
const observe = (event: Event) => { editable = isPluginShortcutEditableEvent(event); assert.equal(event.target, shadow.host); };
dom.window.addEventListener('keydown', observe, { once: true });
await act(async () => element.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'w', code: 'KeyW', ctrlKey: true, bubbles: true, composed: true, cancelable: true })));
assert.equal(editable, true);
assert.deepEqual(actions, []);
}
}
await act(async () => (shadows[0].querySelector('button') as HTMLButtonElement).focus());
assert.equal(isPluginViewEditingTarget(document.activeElement), false, 'focus marker clears when editing ends');
}
} finally {
dom.window.removeEventListener('keydown', capture as unknown as EventListener, true);
await act(async () => root.unmount()); dom.window.close();
for (const [key, value] of saved) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
}
});
+120
View File
@@ -0,0 +1,120 @@
import React, { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { LazyLoadBoundary } from '../ui/lazy-load-boundary';
import { PluginViewPopover } from './PluginViewPopover';
import { createPortal } from 'react-dom';
import { validatePluginRemoteTree } from '@/domain/pluginRemoteTreePolicy.cjs';
import { validatePluginStylesheet } from '@/domain/pluginViewStylePolicy.cjs';
type RemoteNode = { id: string; type: 3; data: string } | { id: string; type: 8; data: string } | {
id: string; type: 1; element: string; attributes: Record<string, string>; children: RemoteNode[];
};
export type PluginSurfaceEvent = import('@netcatty/plugin-contract').HostDrawnViewEvent;
const ATTRIBUTE_NAMES: Record<string, string> = { class: 'className', for: 'htmlFor', readonly: 'readOnly', tabindex: 'tabIndex', maxlength: 'maxLength', minlength: 'minLength', colspan: 'colSpan', rowspan: 'rowSpan', autocomplete: 'autoComplete' };
const BOOLEAN_ATTRIBUTES = new Set(['checked', 'disabled', 'readonly', 'required', 'multiple', 'selected', 'open']);
const SurfaceContext = createContext<{ css: string; onEvent(event: PluginSurfaceEvent): void; depth: number }>({ css: '', onEvent: () => {}, depth: 0 });
const ResourceLoader = createContext<((path: string) => Promise<string>) | undefined>(undefined);
function RemoteElement({ node, settled }: { node: RemoteNode; settled: boolean }): React.ReactNode {
const attrs = node.type === 1 ? node.attributes : {};
const surface = useContext(SurfaceContext);
const loadImage = useContext(ResourceLoader);
const imagePath = node.type === 1 && node.element === 'img' ? attrs.src?.slice(6) : undefined;
const [imageUrl, setImageUrl] = useState<string | undefined>();
useEffect(() => {
let cancelled = false; setImageUrl(undefined);
if (imagePath && loadImage) void loadImage(imagePath).then((url) => { if (!cancelled) setImageUrl(url); }).catch(() => {});
return () => { cancelled = true; };
}, [imagePath, loadImage]);
const remoteSelection = JSON.stringify(node.type === 1 && node.element === 'select'
? node.children.filter((child) => child.type === 1 && child.element === 'option' && Object.hasOwn(child.attributes, 'selected')).map((child) => child.type === 1 ? child.attributes.value ?? '' : '') : []);
const [localSelection, setLocalSelection] = useState<string[]>(() => JSON.parse(remoteSelection));
useEffect(() => { if (settled) setLocalSelection(JSON.parse(remoteSelection)); }, [remoteSelection, settled]);
const remoteValue = attrs.value;
const remoteChecked = Object.hasOwn(attrs, 'checked');
const [localValue, setLocalValue] = useState(remoteValue ?? '');
const [localChecked, setLocalChecked] = useState(remoteChecked);
useEffect(() => { if (settled && remoteValue !== undefined) setLocalValue(remoteValue); }, [remoteValue, settled]);
useEffect(() => { if (settled) setLocalChecked(remoteChecked); }, [remoteChecked, settled]);
if (node.type === 3) return node.data;
if (node.type === 8) return null;
if (attrs['data-plugin-popover'] === 'true') return surface.depth < 1 ? <PluginViewPopover anchor={attrs['data-plugin-anchor'] ?? ''} open={Object.hasOwn(attrs, 'open')} dismiss={() => surface.onEvent({ nodeId: node.id, type: 'dismiss' })}><PluginRemoteSurface tree={{ children: node.children }} css={surface.css} onEvent={surface.onEvent} settled={settled} loadImage={loadImage} depth={surface.depth + 1} /></PluginViewPopover> : null;
const props: Record<string, unknown> = { 'data-netcatty-remote-node': node.id };
let inlineStyle: string | undefined;
for (const [name, value] of Object.entries(node.attributes)) {
if (name === 'src' || (node.element === 'option' && name === 'selected')) continue;
if (name === 'style') { inlineStyle = value; continue; }
if (name === 'data-netcatty-remote-node') continue;
props[ATTRIBUTE_NAMES[name] ?? name] = BOOLEAN_ATTRIBUTES.has(name) ? true : value;
}
// The policy validated this declaration list before this component rendered.
props.ref = (el: HTMLElement | null) => { if (el) { if (inlineStyle === undefined) el.removeAttribute('style'); else el.setAttribute('style', inlineStyle); } };
if (node.element === 'img') props.src = imageUrl;
if (node.element === 'form') props.onSubmit = (event: React.FormEvent) => event.preventDefault();
if (['input', 'select', 'textarea'].includes(node.element)) {
if (node.element === 'select' && Object.hasOwn(attrs, 'multiple')) props.value = localSelection;
else if (remoteValue !== undefined) props.value = localValue;
props.autoComplete = 'off';
if (node.element === 'input' && ['checkbox', 'radio'].includes(attrs.type ?? '')) props.checked = localChecked;
props.onChange = (event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setLocalValue(event.currentTarget.value);
if (event.currentTarget instanceof event.currentTarget.ownerDocument.defaultView!.HTMLSelectElement && event.currentTarget.multiple) setLocalSelection([...event.currentTarget.selectedOptions].map((option) => option.value));
if (event.currentTarget instanceof event.currentTarget.ownerDocument.defaultView!.HTMLInputElement) setLocalChecked(event.currentTarget.checked);
};
}
return React.createElement(node.element, props, ...(node.element === 'textarea' && remoteValue !== undefined ? [] : node.children).map((child) => <RemoteElement key={child.id} node={child} settled={settled} />));
}
/** Host-owned DOM, kept inside a closed shadow root and a clipped stacking context. */
export function PluginRemoteSurface({ tree, css, onEvent, settled = true, loadImage, depth = 0 }: { depth?: number; tree: unknown; css: string; settled?: boolean; loadImage?(path: string): Promise<string>; onEvent(event: PluginSurfaceEvent): void }) {
const checked = useMemo(() => {
try {
validatePluginRemoteTree(tree);
return { tree: tree as { children: RemoteNode[] }, css: validatePluginStylesheet(css) };
} catch { return null; }
}, [tree, css]);
if (!checked) return <p role="alert" className="p-4 text-sm text-destructive">Unable to display plugin view.</p>;
return <LazyLoadBoundary name="Plugin view" fallback={<p role="alert" className="p-4 text-sm text-destructive">Unable to display plugin view.</p>}><SurfaceContext.Provider value={{ css: checked.css, onEvent, depth }}><ResourceLoader.Provider value={loadImage}><ValidatedPluginRemoteSurface tree={checked.tree} css={checked.css} onEvent={onEvent} settled={settled} /></ResourceLoader.Provider></SurfaceContext.Provider></LazyLoadBoundary>;
}
function ValidatedPluginRemoteSurface({ tree, css, onEvent, settled }: { settled: boolean; tree: { children: RemoteNode[] }; css: string; onEvent(event: PluginSurfaceEvent): void }) {
const hostRef = useRef<HTMLDivElement>(null);
const onEventRef = useRef(onEvent); onEventRef.current = onEvent;
const shadowRef = useRef<ShadowRoot | null>(null);
const [shadow, setShadow] = useState<ShadowRoot | null>(null);
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
// The same DOM node can be mounted twice in React Strict Mode.
const root = shadowRef.current ?? host.attachShadow({ mode: 'closed' });
shadowRef.current = root;
setShadow(root);
}, []);
useEffect(() => {
if (!shadow) return;
const listener = (event: Event) => {
const target = event.target;
const view = shadow.ownerDocument.defaultView;
if (!view || !(target instanceof view.HTMLElement) || !['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset'].includes(event.type)) return;
// Closed-shadow events hide their editable target from window capture.
if (event.type === 'focusin') hostRef.current?.toggleAttribute('data-plugin-editing', target.matches('input, textarea, select, [contenteditable]'));
if (event.type === 'focusout') hostRef.current?.removeAttribute('data-plugin-editing');
if (event.type === 'submit') event.preventDefault();
const nodeId = target.dataset.netcattyRemoteNode;
if (!nodeId) return;
const value = target instanceof view.HTMLInputElement || target instanceof view.HTMLTextAreaElement || target instanceof view.HTMLSelectElement ? target.value : undefined;
const values = target instanceof view.HTMLSelectElement && target.multiple ? [...target.selectedOptions].map((option) => option.value) : undefined;
const checkedValue = target instanceof view.HTMLInputElement && ['checkbox', 'radio'].includes(target.type) ? target.checked : undefined;
if (value !== undefined && value.length > 8192) return;
const keyboard = event instanceof view.KeyboardEvent && event.key.length <= 64
? { key: event.key, ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, shiftKey: event.shiftKey } : {};
onEventRef.current({ nodeId, type: event.type as PluginSurfaceEvent['type'], ...(value === undefined ? {} : { value }), ...(checkedValue === undefined ? {} : { checked: checkedValue }), ...keyboard, ...(values ? { values } : {}), ...(event instanceof view.CompositionEvent ? { data: event.data.slice(0, 8192) } : {}) });
};
for (const event of ['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset']) shadow.addEventListener(event, listener, true);
return () => { for (const event of ['click', 'input', 'change', 'keydown', 'keyup', 'focusin', 'focusout', 'compositionstart', 'compositionupdate', 'compositionend', 'submit', 'reset']) shadow.removeEventListener(event, listener, true); };
}, [shadow]);
return <div className="h-full w-full min-h-0 min-w-0 overflow-hidden [contain:paint] isolate relative" data-plugin-remote-surface>
<div ref={hostRef} className="h-full min-h-0 overflow-auto [container-type:inline-size]" />
{shadow && createPortal(<><style>{css}</style>{tree.children.map((node) => <RemoteElement key={node.id} node={node} settled={settled} />)}</>, shadow)}
</div>;
}
@@ -0,0 +1,85 @@
import { Settings } from 'lucide-react';
import { useEffect, useRef, useState, type RefObject } from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { usePluginSettingsModel } from '../../application/state/usePluginSettingsModel';
import { useAvailableFonts } from '../../application/state/fontStore';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
import { PluginSettingGroups } from './PluginSettingsForm';
export const pluginSettingsButtonClass = 'h-8 w-8 shrink-0 rounded-full border border-border/60 bg-background/95 shadow-sm hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
export function PluginSettingsDialog({ pluginId, onClose, restoreFocusTo }: {
pluginId: string;
onClose: () => void;
restoreFocusTo: RefObject<HTMLElement | null>;
}) {
const { t } = useI18n();
const model = usePluginSettingsModel(pluginId);
const fonts = useAvailableFonts();
useEffect(() => {
if (!model.loading && !model.error && !model.settings.length) onClose();
}, [model.loading, model.error, model.settings.length, onClose]);
return <Dialog open onOpenChange={open => { if (!open) onClose(); }}>
<DialogContent data-plugin-settings-dialog={pluginId}
className="app-no-drag flex max-h-[min(80dvh,760px)] w-[calc(100vw-2rem)] max-w-[720px] flex-col gap-0 overflow-hidden rounded-xl p-0"
overlayClassName="bg-black/40"
onEscapeKeyDown={event => event.stopPropagation()}
onCloseAutoFocus={event => {
const element = restoreFocusTo.current;
if (element?.isConnected && !element.matches(':disabled')) {
event.preventDefault(); element.focus({ preventScroll: true });
}
}}>
<DialogHeader className="shrink-0 border-b border-border/60 px-6 py-4 pr-14">
<DialogTitle className="flex min-w-0 items-center gap-2.5 text-base">
<Settings size={18} aria-hidden="true" className="shrink-0 text-muted-foreground" />
<span className="truncate">{model.plugin?.displayName ?? t('settings.plugins.configuration')}</span>
</DialogTitle>
</DialogHeader>
<div className="min-h-0 overflow-y-auto p-6" data-plugin-settings-form="">
{model.loading && <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{model.error && <p role="alert" className="text-sm text-destructive">{model.error.message}</p>}
{model.contextualScopes.length > 0 && <section className="mb-4 grid gap-3 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{model.contextualScopes.map(kind => <label key={kind} className="space-y-1 text-xs font-medium">
<span>{t('settings.plugins.scopeTarget').replace('{scope}', kind)}</span>
<select value={model.scopeIds[kind] ?? ''}
onChange={event => model.setScopeIds(current => ({ ...current, [kind]: event.target.value || undefined }))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-normal">
{!model.catalog[kind].length && <option value="">{t('settings.plugins.noScopeTargets')}</option>}
{model.catalog[kind].map(entry => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select>
</label>)}
</section>}
<div className="space-y-4">
<PluginSettingGroups pluginId={pluginId} settings={model.settings} updateSetting={model.updateSetting}
resetSetting={model.resetSetting} selectSettingPath={model.selectSettingPath} availableFonts={fonts} />
</div>
</div>
</DialogContent>
</Dialog>;
}
function AvailableSettingsEntry({ pluginId, name }: { pluginId: string; name: string }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const opener = useRef<HTMLButtonElement>(null);
return <>
<div className="flex items-center gap-3">
<Button ref={opener} type="button" variant="ghost" size="icon" className={pluginSettingsButtonClass}
aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t('common.settings')}
onClick={() => setOpen(true)}><Settings size={16} /></Button>
</div>
{open && <PluginSettingsDialog pluginId={pluginId} onClose={() => setOpen(false)} restoreFocusTo={opener} />}
</>;
}
export function PluginSettingsEntry({ pluginId }: { pluginId: string }) {
const { locale } = useI18n();
const contributions = usePluginContributions({ locale, context: { 'netcatty.surface': 'settings' } });
const plugin = contributions.snapshot.plugins.find(item => item.id === pluginId);
if (!plugin?.settings.some(setting => setting.visible)) return null;
// Removing or replacing the owning contribution also discards its open dialog.
return <AvailableSettingsEntry key={`${plugin.id}:${plugin.version}`} pluginId={plugin.id} name={plugin.displayName} />;
}
+275
View File
@@ -0,0 +1,275 @@
import React, { useEffect, useState } from 'react';
import { FolderOpen, RotateCcw } from 'lucide-react';
import { normalizePluginKeyboardEvent } from '../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useAvailableFonts } from '../../application/state/fontStore';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Toggle } from '../settings/settings-ui';
import { parsePluginStructuredSettingValue } from '../settings/tabs/pluginSettingValues';
import { PluginStructuredSettingEditor } from '../settings/tabs/PluginStructuredSettingEditor';
import { TerminalFontSelect } from '../settings/TerminalFontSelect';
export function PluginSettingField({
pluginId,
setting,
updateSetting,
resetSetting,
selectSettingPath,
availableFonts,
}: {
pluginId: string;
setting: NetcattyPluginSettingContribution;
updateSetting: ReturnType<typeof usePluginContributions>['updateSetting'];
resetSetting: ReturnType<typeof usePluginContributions>['resetSetting'];
selectSettingPath: ReturnType<typeof usePluginContributions>['selectSettingPath'];
availableFonts: ReturnType<typeof useAvailableFonts>;
}) {
const { t } = useI18n();
const [value, setValue] = useState<unknown>(setting.secret ? '' : setting.value ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const itemLabel = (key: 'settings.plugins.moveItemUp' | 'settings.plugins.moveItemDown', index: number) => (
t(key).replace('{label}', setting.label).replace('{index}', String(index + 1))
);
useEffect(() => {
setValue(setting.secret ? '' : setting.value ?? '');
}, [setting.secret, setting.value]);
const save = async (nextValue: unknown) => {
setSaving(true);
setError(null);
try {
await updateSetting(pluginId, setting.id, nextValue, setting.scopeId ?? undefined);
if (setting.secret) setValue('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const control = (() => {
if (setting.scopeId == null) {
return <div className="text-xs text-muted-foreground">{t('settings.plugins.scopeContext').replace('{scope}', setting.scope)}</div>;
}
if (setting.control === 'switch') {
return <Toggle ariaLabel={setting.label} checked={Boolean(value)} disabled={saving}
onChange={(next) => { setValue(next); void save(next); }} />;
}
if (setting.control === 'radio') {
return (
<fieldset className="space-y-2" disabled={saving}>
<legend className="sr-only">{setting.label}</legend>
{setting.options?.map((option) => (
<label key={option.value} className="flex items-start gap-2 text-sm">
<input
type="radio"
name={setting.id}
value={option.value}
checked={value === option.value}
onChange={() => { setValue(option.value); void save(option.value); }}
className="mt-0.5 accent-primary"
/>
<span>{option.label}{option.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}</span>
</label>
))}
</fieldset>
);
}
if (setting.control === 'select') {
return (
<select
aria-label={setting.label}
value={String(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.value); void save(event.target.value); }}
className="h-9 min-w-52 rounded-md border border-input bg-background px-3 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'multiselect') {
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<select
aria-label={setting.label}
multiple
value={selected}
disabled={saving}
onChange={(event) => {
const next = [...event.currentTarget.selectedOptions].map((option) => option.value);
setValue(next);
void save(next);
}}
className="min-h-24 min-w-52 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'textarea') {
return (
<textarea
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => void save(event.currentTarget.value)}
className="min-h-24 w-full max-w-xl rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
);
}
if (setting.control === 'number' || setting.control === 'slider') {
return (
<Input
aria-label={setting.label}
type={setting.control === 'slider' ? 'range' : 'number'}
value={typeof value === 'number' ? value : Number(value) || 0}
min={setting.minimum}
max={setting.maximum}
step={setting.step}
disabled={saving}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => void save(Number(event.currentTarget.value))}
className="max-w-sm"
/>
);
}
if (setting.control === 'list' || setting.control === 'table') {
return (
<PluginStructuredSettingEditor
setting={setting}
value={typeof value === 'string' ? parsePluginStructuredSettingValue(value) : value}
disabled={saving}
onChange={setValue}
onCommit={(next) => void save(next)}
labels={{
add: t('settings.plugins.addItem'),
remove: t('settings.plugins.removeItem'),
moveUp: (index) => itemLabel('settings.plugins.moveItemUp', index),
moveDown: (index) => itemLabel('settings.plugins.moveItemDown', index),
}}
/>
);
}
if (setting.control === 'font') {
return (
<TerminalFontSelect
value={String(value)}
fonts={availableFonts}
disabled={saving}
onChange={(next) => { setValue(next); void save(next); }}
className="w-full max-w-xl"
ariaLabel={setting.label}
/>
);
}
if (setting.control === 'file' || setting.control === 'directory') {
return (
<div className="flex max-w-xl gap-2">
<Input aria-label={setting.label} value={String(value)} readOnly disabled={saving} className="min-w-0 flex-1" />
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => void selectSettingPath(setting.control as 'file' | 'directory', setting.label, String(value || '')).then((selected) => {
if (!selected) return;
setValue(selected);
void save(selected);
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))}
>
<FolderOpen size={14} className="mr-2" /> {t('settings.plugins.browse')}
</Button>
</div>
);
}
if (setting.control === 'keybinding') {
return (
<Input
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder ?? t('settings.plugins.pressKeybinding')}
readOnly
disabled={saving}
onKeyDown={(event) => {
event.preventDefault();
const key = normalizePluginKeyboardEvent(event.nativeEvent);
if (!key) return;
setValue(key);
void save(key);
}}
className="max-w-xl"
/>
);
}
return (
<Input
aria-label={setting.label}
type={setting.secret || setting.control === 'password' ? 'password' : setting.control === 'color' ? 'color' : 'text'}
value={String(value)}
placeholder={setting.secret && setting.configured ? t('settings.plugins.configuredReplacement') : setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => {
const next = event.currentTarget.value;
if (!setting.secret || next.length > 0) void save(next);
}}
className="max-w-xl"
/>
);
})();
const expanded = ['textarea', 'list', 'table', 'radio', 'multiselect'].includes(setting.control);
const reset = !setting.required && setting.scopeId != null && <Button
type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground"
title={t('common.reset')} aria-label={t('common.reset')} disabled={saving}
onClick={() => {
setSaving(true); setError(null);
void resetSetting(pluginId, setting.id, setting.scopeId ?? undefined)
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setSaving(false));
}}><RotateCcw size={14} /></Button>;
return <div className="space-y-2 py-3" data-plugin-setting={setting.id}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0 flex-1 basis-40">
<div className="text-sm font-medium">{setting.label}</div>
{setting.description && <p className="mt-1 max-w-2xl text-xs leading-relaxed text-muted-foreground">{setting.description}</p>}
{setting.restartRequired && <span className="text-xs text-amber-700 dark:text-amber-300">{t('settings.plugins.restartRequired')}</span>}
</div>
<div className="flex max-w-full items-center justify-end gap-2">
{!expanded && <div className={setting.control === 'switch' ? 'shrink-0' : 'w-56 max-w-full'}>{control}</div>}
{reset}
</div>
</div>
{expanded && control}
{(saving || (setting.secret && setting.configured) || error) && <div className="flex flex-wrap items-center gap-2">
{saving && <span role="status" className="text-xs text-muted-foreground">{t('settings.plugins.saving')}</span>}
{setting.secret && setting.configured && <span className="text-xs text-emerald-600">{t('settings.plugins.storedSecurely')}</span>}
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
</div>}
</div>;
}
export function PluginSettingGroups({ settings, ...props }: Omit<Parameters<typeof PluginSettingField>[0], 'setting'> & {
settings: ReadonlyArray<NetcattyPluginSettingContribution>;
}) {
const groups = new Map<string, NetcattyPluginSettingContribution[]>();
for (const setting of settings) {
if (!setting.visible) continue;
const group = setting.group ?? '';
groups.set(group, [...(groups.get(group) ?? []), setting]);
}
return <>{[...groups].map(([group, fields]) => <div key={group} className="space-y-3">
{group && <h4 className="pt-2 text-sm font-medium text-muted-foreground">{group}</h4>}
<div className="divide-y divide-border/50 rounded-xl bg-muted/30 px-4">
{fields.map((setting) => <PluginSettingField key={setting.id} {...props} setting={setting} />)}
</div>
</div>)}</>;
}
+17 -24
View File
@@ -1,40 +1,40 @@
import { useMemo } from 'react';
import { Blocks } from 'lucide-react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { activeTabStore } from '../../application/state/activeTabStore';
import { subscribeSidePanelLiveSnapshot } from '../../application/state/sidePanelLiveStore';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import { usePluginContributions } from '../../application/state/usePluginContributions';
import { collectPluginTerminalSidebarPages, PLUGIN_TERMINAL_SIDEBAR_CONTEXT, requestOpenPluginTerminalSidebar,
type PluginTerminalSidebarPage } from '../../application/state/usePluginTerminalSidebar';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
export function PluginTerminalSidebarLauncher({ sessionId }: { sessionId: string }) {
const { locale, t } = useI18n();
export function PluginTerminalSidebarLauncher({ sessionId, buttonClassName = 'h-6 w-6' }: { sessionId: string; buttonClassName?: string }) {
const { locale } = useI18n();
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
const pages = collectPluginTerminalSidebarPages(contributions.snapshot.plugins);
if (!pages.length) return null;
return <Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6" aria-label={t('plugins.sidebar.extensions')}><Blocks size={14} /></Button></PopoverTrigger>
<PopoverContent align="end" className="max-h-80 w-64 overflow-y-auto p-1">
<p className="px-2 py-1 text-xs text-muted-foreground">{t('plugins.sidebar.extensions')}</p>
{pages.map((page) => <PopoverClose asChild key={page.tool}><button type="button" className="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-sm hover:bg-accent"
return <>{pages.map((page) => <Tooltip key={page.tool}>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className={buttonClassName}
aria-label={`${page.title} — ${page.pluginName}`}
onClick={() => requestOpenPluginTerminalSidebar(page.viewId, sessionId)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} /><span className="min-w-0"><span className="block truncate">{page.title}</span><span className="block truncate text-xs text-muted-foreground">{page.pluginName}</span></span>
</button></PopoverClose>)}
</PopoverContent></Popover>;
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={14} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{page.title} — {page.pluginName}</TooltipContent>
</Tooltip>)}</>;
}
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter, getSession, unavailable, focused, focusOnReveal }: {
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, getSession, unavailable, focused, showHeader = true }: {
page: PluginTerminalSidebarPage;
tabId: string;
sessionId: string;
adapter: ReturnType<typeof usePluginContributions>;
getSession(): string | null;
unavailable(): void;
focused(): void;
focusOnReveal: boolean;
showHeader?: boolean;
}) {
const { t } = useI18n();
const subscribeHidden = useMemo(() => (hide: () => void) => {
@@ -42,15 +42,8 @@ export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter,
const stopSession = subscribeSidePanelLiveSnapshot(true, () => { if (getSession() !== sessionId) hide(); });
return () => { stopTab(); stopSession(); };
}, [getSession, sessionId, tabId]);
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version,
slot: `terminal-sidebar:${tabId}:${sessionId}`, active: true, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT,
adapter, subscribeHidden, unavailable, focused, focusOnReveal });
// The native page never receives these internal ownership IDs. Its state is
// scoped by the host; this does not authorize reading the terminal session.
return <section className="flex h-full min-h-0 flex-col overflow-hidden" aria-label={`${page.title} — ${page.pluginName}`} data-plugin-terminal-sidebar>
<header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title} · {page.pluginName}</span></header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="terminal-sidebar">
{loading && <p role="status" className="p-3 text-xs text-muted-foreground">{t('plugins.sidebar.loading')}</p>}
</div>
{showHeader && <header className="flex h-7 shrink-0 items-center gap-2 border-b border-border px-2 text-xs"><PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={12} /><span className="truncate">{page.title}</span></header>}
<div className="min-h-0 flex-1" onFocusCapture={focused}><PluginHostDrawnPage pluginId={page.pluginId} viewId={page.viewId} version={page.version} slot={`terminal-sidebar:${tabId}:${sessionId}`} active context={PLUGIN_TERMINAL_SIDEBAR_CONTEXT} subscribeHidden={subscribeHidden} unavailable={unavailable} loadingLabel={t('plugins.sidebar.loading')} /></div>
</section>;
}
+7 -17
View File
@@ -1,44 +1,34 @@
import { activeTabStore } from '../../application/state/activeTabStore';
import { useI18n } from '../../application/i18n/I18nProvider';
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
import type { usePluginVaultPages } from '../../application/state/usePluginVaultPages';
import { PluginContributionIcon } from './PluginContributionIcon';
import { Button } from '../ui/button';
import { PluginHostDrawnPage } from './PluginHostDrawnPage';
type VaultPages = ReturnType<typeof usePluginVaultPages>;
const subscribeHidden = (hide: () => void) => activeTabStore.subscribeSync((id) => { if (id !== 'vault') hide(); });
export function PluginVaultNavigation({ model, collapsed }: { model: VaultPages; collapsed: boolean }) {
const { t } = useI18n();
if (!model.pages.length) return null;
return <div role="group" aria-label={t('plugins.vault.extensions')} className="mt-3 space-y-1 border-t border-border/50 pt-3">
{!collapsed && <p className="px-2 text-xs text-muted-foreground">{t('plugins.vault.extensions')}</p>}
return <>
{model.pages.map((page) => <Button key={page.key} type="button"
variant={model.selected?.key === page.key ? 'secondary' : 'ghost'}
className={`w-full ${collapsed ? 'justify-center px-0' : 'justify-start gap-3'}`}
className={`w-full h-10 ${collapsed ? 'justify-center p-0' : 'justify-start gap-3'} ${model.selected?.key === page.key ? 'bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40' : ''}`}
title={`${page.title} — ${page.pluginName}`}
aria-label={`${page.title} — ${page.pluginName}`}
aria-current={model.selected?.key === page.key ? 'page' : undefined}
onClick={() => model.select(page.key)}>
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} className="shrink-0" />
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={16} className="shrink-0" />
{!collapsed && <span className="truncate">{page.title}</span>}
</Button>)}
</div>;
</>;
}
export function PluginVaultPage({ model }: { model: VaultPages }) {
const { t } = useI18n();
const page = model.selected!;
const { mountRef, loading } = useEmbeddedPluginView({ viewId: page.viewId, version: page.version, slot: 'vault-page',
active: model.active, context: model.context, adapter: model.contributions, subscribeHidden, unavailable: model.fallback });
return <section className="flex min-h-0 flex-1 flex-col" aria-label={page.title} data-plugin-vault-page={page.key}>
<header className="app-no-drag flex h-12 shrink-0 items-center justify-between gap-3 border-b border-border px-4">
<div className="min-w-0"><h2 className="truncate text-sm font-medium">{page.title}</h2>
<p className="truncate text-xs text-muted-foreground">{page.pluginName} · {page.version}</p></div>
<Button variant="ghost" onClick={model.fallback}>{t('plugins.vault.back')}</Button>
</header>
<div ref={mountRef} className="relative min-h-0 flex-1 overflow-hidden" data-plugin-view-slot="vault-page">
{loading && <p role="status" className="p-4 text-sm text-muted-foreground">{t('plugins.vault.loading')}</p>}
</div>
<PluginHostDrawnPage pluginId={page.pluginId} viewId={page.viewId} version={page.version} slot="vault-page" active={model.active} context={model.context}
subscribeHidden={subscribeHidden} unavailable={model.fallback} loadingLabel={t('plugins.vault.loading')} />
</section>;
}
+41
View File
@@ -0,0 +1,41 @@
import React, { useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
/** Geometry and dismissal belong to the host; content belongs to the plugin. */
export function PluginViewPopover({ anchor, open, dismiss, children }: {
anchor: string; open: boolean; dismiss(): void; children: React.ReactNode;
}) {
const marker = useRef<HTMLSpanElement>(null);
const panel = useRef<HTMLDivElement>(null);
const dismissRef = useRef(dismiss); dismissRef.current = dismiss;
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null);
useLayoutEffect(() => {
if (!open || !marker.current) { setPosition(null); return; }
const root = marker.current.getRootNode() as ShadowRoot;
const target = root.getElementById(anchor);
if (!(target instanceof marker.current.ownerDocument.defaultView!.HTMLElement)) return;
const doc = target.ownerDocument; const win = doc.defaultView!;
const update = () => {
const bounds = target.getBoundingClientRect();
if (!bounds.width || !bounds.height) { setPosition(null); dismissRef.current(); return; }
const width = Math.min(360, Math.max(220, bounds.width), win.innerWidth - 16);
setPosition({ left: Math.max(8, Math.min(bounds.left, win.innerWidth - width - 8)), top: Math.max(8, Math.min(bounds.bottom + 4, win.innerHeight - 248)), width });
};
const dismissNow = () => { setPosition(null); dismissRef.current(); };
const key = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); dismissNow(); target.focus(); } };
const outside = (event: PointerEvent) => { if (!event.composedPath().includes(target) && (!panel.current || !event.composedPath().includes(panel.current))) dismissNow(); };
const blocked = () => [...doc.querySelectorAll('[role=dialog][aria-modal=true]:not([data-plugin-dialog])')].some((dialog) => {
const rect = dialog.getBoundingClientRect(); return rect.width > 0 && rect.height > 0;
});
if (blocked()) { dismissNow(); return; }
const dialogs = new win.MutationObserver(() => { if (blocked()) dismissNow(); });
dialogs.observe(doc.body, { subtree: true, childList: true, attributes: true, attributeFilter: ['aria-modal', 'hidden', 'data-state'] });
const resize = new win.ResizeObserver(update); resize.observe(target); update();
win.addEventListener('resize', update); doc.addEventListener('scroll', update, true);
doc.addEventListener('keydown', key, true); doc.addEventListener('pointerdown', outside, true);
return () => { dialogs.disconnect(); resize.disconnect(); win.removeEventListener('resize', update); doc.removeEventListener('scroll', update, true); doc.removeEventListener('keydown', key, true); doc.removeEventListener('pointerdown', outside, true); };
}, [anchor, open]);
return <><span ref={marker} hidden />{open && position && marker.current && createPortal(
<div ref={panel} role="region" aria-label="Plugin menu" data-plugin-view-popover className="fixed z-[45] overflow-hidden rounded-lg border border-border bg-background shadow-xl" style={{ ...position, height: 240, maxHeight: 'calc(100vh - 16px)', contain: 'paint', isolation: 'isolate' }}>{children}</div>, marker.current.ownerDocument.body,
)}</>;
}
+6 -6
View File
@@ -1,11 +1,11 @@
import React, { useRef, useState } from 'react';
import { Info } from 'lucide-react';
import { CircleAlert, Info } from 'lucide-react';
import { Button } from '../../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip';
/** Hover/focus previews explanatory text; click pins it in the existing accessible popover. */
export function PluginInfo({ label, children, badgeCount, previewContent }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode }) {
export function PluginInfo({ label, children, badgeCount, previewContent, variant = 'info' }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode; variant?: 'info' | 'error' }) {
const [pinned, setPinned] = useState(false);
const [preview, setPreview] = useState(false);
const suppressFocusPreview = useRef(false);
@@ -13,13 +13,13 @@ export function PluginInfo({ label, children, badgeCount, previewContent }: { la
<TooltipProvider delayDuration={200}>
<Tooltip open={!pinned && preview} onOpenChange={(open) => { if (!open || !suppressFocusPreview.current) setPreview(open); }}>
<TooltipTrigger asChild><PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="relative h-8 w-8 shrink-0 p-0 text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" aria-label={label} onBlur={() => { suppressFocusPreview.current = false; }} onPointerMove={() => { suppressFocusPreview.current = false; }}>
<Info size={15} />{Boolean(badgeCount) && <span aria-hidden="true" className="absolute right-0 top-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-muted px-0.5 text-[9px] font-medium leading-none text-foreground">{badgeCount}</span>}
<Button type="button" variant="ghost" size="sm" className={`relative shrink-0 p-0 focus-visible:ring-2 focus-visible:ring-ring ${variant === 'error' ? 'h-6 w-6 text-red-600 hover:bg-red-500/10 hover:text-red-600 dark:text-red-400 dark:hover:text-red-400' : 'h-8 w-8 text-muted-foreground'}`} aria-label={label} onBlur={() => { suppressFocusPreview.current = false; }} onPointerMove={() => { suppressFocusPreview.current = false; }}>
{variant === 'error' ? <CircleAlert size={15} /> : <Info size={15} />}{Boolean(badgeCount) && <span aria-hidden="true" className="absolute right-0 top-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-muted px-0.5 text-[9px] font-medium leading-none text-foreground">{badgeCount}</span>}
</Button>
</PopoverTrigger></TooltipTrigger>
<TooltipContent className="max-w-xs space-y-2 bg-popover p-3 text-xs leading-5 text-popover-foreground shadow-sm">{previewContent ?? children}</TooltipContent>
<TooltipContent className="max-h-64 max-w-xs space-y-2 overflow-y-auto break-words bg-popover p-3 text-xs leading-5 text-popover-foreground shadow-sm">{previewContent ?? children}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent onEscapeKeyDown={() => { suppressFocusPreview.current = true; }} aria-label={label} align="end" className="max-w-xs space-y-2 text-xs leading-5">{children}</PopoverContent>
<PopoverContent onEscapeKeyDown={() => { suppressFocusPreview.current = true; }} aria-label={label} align="end" className="max-h-64 max-w-xs space-y-2 overflow-y-auto break-words text-xs leading-5">{children}</PopoverContent>
</Popover>;
}
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
@@ -7,6 +8,12 @@ import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('installed grid filters, opens one detail and restores search, scroll and card focus without pill navigation', async () => {
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
const previousDom = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document })) {
previousDom.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const raf = globalThis.requestAnimationFrame;
@@ -39,7 +46,8 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.ok(authorRow.props.className.includes('gap-3'), 'author and source groups have 12px separation');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
assert.doesNotMatch(JSON.stringify(renderer.toJSON()), /Incompatible version/, 'error details stay hidden until requested');
assert.ok(renderer.root.findAllByType('button').some((node) => node.props['aria-label']?.includes('Beta') && node.props['aria-haspopup'] === 'dialog'), 'error icon opens details without navigating the card');
assert.ok(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')));
const pill = renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
await act(async () => pill.props.onClick());
@@ -63,5 +71,11 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.equal(unknown.length, 2);
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
} finally {
globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf;
dom.window.close();
for (const [key, descriptor] of previousDom) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -66,20 +66,6 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.equal(unmounts, 2);
let handedOff = false;
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog>{(handoff) => <button onClick={() => handoff(() => {
handedOff = true;
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.notEqual(document.body.style.pointerEvents, 'none');
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
// Radix schedules close-focus cleanup after the React close commit. The
// settle inside click's act() can precede that commit; observe the actual
// handoff instead of assuming the cleanup timer already ran.
const handoffDeadline = Date.now() + 2_000;
while (!handedOff && Date.now() < handoffDeadline) await act(async () => { await settle(); });
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
const browse = useRef<HTMLButtonElement | null>(null);
@@ -1,4 +1,4 @@
import React, { useRef, useState } from 'react';
import React, { useState } from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
@@ -10,41 +10,30 @@ export function PluginManagementDialog({ children, trigger, open: controlledOpen
onOpenChange?: (open: boolean) => void;
restoreFocusTo?: React.RefObject<HTMLElement | null>;
fallbackFocusTo?: React.RefObject<HTMLElement | null>;
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
children: React.ReactNode;
}) {
const { t } = useI18n();
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = (value: boolean) => { setInternalOpen(value); onOpenChange?.(value); };
const pendingHandoff = useRef<(() => void) | null>(null);
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
return (
<Dialog open={open} onOpenChange={setOpen}>
{trigger !== null && <DialogTrigger asChild>
{trigger ?? <Button variant="outline">{t('settings.plugins.openManager')}</Button>}
</DialogTrigger>}
<DialogContent onCloseAutoFocus={(event) => {
const action = pendingHandoff.current;
pendingHandoff.current = null;
if (action) {
const previous = restoreFocusTo?.current;
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
if (target?.isConnected) {
event.preventDefault();
// Wait for Radix to release modal pointer/focus ownership before opening
// the existing external plugin view host (including exit animations).
queueMicrotask(action);
} else {
const previous = restoreFocusTo?.current;
const target = previous?.isConnected && !previous.matches(':disabled') ? previous : fallbackFocusTo?.current;
if (target?.isConnected) {
event.preventDefault();
target.focus({ preventScroll: true });
}
target.focus({ preventScroll: true });
}
}} className="app-no-drag flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<DialogTitle>{t('settings.plugins.title')}</DialogTitle>
</DialogHeader>
<div data-settings-scroll-pane className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 sm:p-6">
{typeof children === 'function' ? children(handoff) : children}
{children}
</div>
</DialogContent>
</Dialog>
@@ -21,7 +21,8 @@ test('installed cards and selected details keep author before compact source met
assert.equal(row.querySelector('[data-plugin-source]')?.textContent, 'Local');
assert.match(row.textContent!, /PublisherLocal/);
assert.doesNotMatch(article.textContent!, /Source:/);
assert.match(article.textContent!, /Diagnostic remains visible/);
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();
}
});
@@ -9,6 +9,8 @@ import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { PluginInfo } from './PluginInfo';
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
const { t, resolvedLocale } = useI18n();
@@ -50,7 +52,7 @@ export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlug
</div>;
}
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
export function PluginPackageManager({ manager, onSelectionChange, initialSelectedId, detailActions }: { initialSelectedId?: string | null; manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void; detailActions?: React.ReactNode }) {
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(initialSelectedId ?? null);
const [search, setSearch] = useState('');
@@ -77,6 +79,7 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => (!sourceFilter || (plugin.source?.id ?? 'local') === sourceFilter) && [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const [removing, setRemoving] = useState<string | null>(null);
const [clearSource, setClearSource] = useState(false);
const [uninstallError, setUninstallError] = useState<string | null>(null);
if (!manager.available) return null;
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
@@ -89,10 +92,18 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg focus:outline-none ${selectedPlugin ? 'space-y-4' : 'plugin-card flex min-h-[200px] flex-col gap-3 bg-card p-4'}`}>
<div className="flex flex-wrap items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
<div className="flex min-w-0 flex-1 items-center gap-1">
<button type="button" data-plugin-card={plugin.id} className="min-w-0 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
<div className="break-words text-sm font-medium">{nameOf(plugin)}</div>
</button>
{plugin.runtime.lastError && <PluginInfo variant="error" label={`${t('settings.plugins.manager.lastError')} — ${nameOf(plugin)}`}>
<p className="font-medium">{t('settings.plugins.manager.lastError')}</p>
<p className="whitespace-pre-wrap [overflow-wrap:anywhere]">{plugin.runtime.lastError}</p>
</PluginInfo>}
</div>
<div className="ml-auto flex shrink-0 items-center gap-2" data-plugin-header-actions="">
{selected === plugin.id && detailActions}
<Button variant="ghost" size="sm" type="button"
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${manager.statusKnown !== false && plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
style={manager.statusKnown !== false && plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
@@ -102,6 +113,7 @@ 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-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
@@ -109,16 +121,31 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => choose(plugin.id)} aria-label={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`}>{t('settings.plugins.manager.details')}</Button>
</div> : <p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>}
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearSource} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id, clearSource); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => { setClearSource(false); setRemoving(plugin.id); }}>{t('settings.plugins.manager.remove')}</Button>}
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }}
actions={<Dialog open={removing === plugin.id} onOpenChange={(open) => {
if (manager.busy) return;
setRemoving(open ? plugin.id : null);
if (open) { setClearSource(false); setUninstallError(null); }
}}>
<DialogTrigger asChild><Button variant="destructive" size="sm" disabled={manager.busy}>{t('settings.plugins.manager.remove')}</Button></DialogTrigger>
<DialogContent hideCloseButton={manager.busy} className="max-h-[85dvh] w-[calc(100vw-2rem)] max-w-md overflow-y-auto" data-plugin-uninstall-confirm="">
<DialogHeader className="pr-6"><DialogTitle>{t('settings.plugins.manager.remove')} · {nameOf(plugin)}</DialogTitle></DialogHeader>
<p className="text-sm text-muted-foreground">{t('settings.plugins.manager.removeNote')}</p>
<label className="flex items-start gap-2 text-sm"><input className="mt-1" type="checkbox" checked={clearSource} disabled={manager.busy} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
{uninstallError && <p role="alert" className="break-words text-sm text-destructive">{uninstallError}</p>}
<DialogFooter>
<Button variant="outline" disabled={manager.busy} onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
<Button variant="destructive" disabled={manager.busy} aria-busy={manager.busy} onClick={async () => {
setUninstallError(null);
try { await manager.uninstall(plugin.id, clearSource); choose(null); }
catch (cause) { setUninstallError(cause instanceof Error ? cause.message : String(cause)); }
}}>{t('settings.plugins.manager.remove')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>}
/>
</>}
</article>)}
</div>
@@ -7,9 +7,10 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '.
import { SettingRow } from '../settings-ui';
/** Host UI only. Plugin views cannot query or mutate this security state. */
export function PluginPermissionPanel({ pluginId, onChanged }: {
export function PluginPermissionPanel({ pluginId, onChanged, actions }: {
pluginId: string;
onChanged: () => Promise<void>;
actions?: React.ReactNode;
}) {
const { t, resolvedLocale } = useI18n();
const [snapshot, setSnapshot] = useState<NetcattyPluginPermissionSnapshot | null>(null);
@@ -102,7 +103,10 @@ export function PluginPermissionPanel({ pluginId, onChanged }: {
</li>)}</ol>
</details>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
<Button variant="destructive" size="sm" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>
</>}
<div className="flex flex-wrap items-center gap-2">
{snapshot && <Button variant="secondary" size="sm" className="bg-amber-500 text-amber-950 hover:bg-amber-400" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>}
{actions}
</div>
</section>;
}
@@ -12,21 +12,23 @@ async function fixture() {
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'DocumentFragment', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
install('requestAnimationFrame', (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
install('cancelAnimationFrame', (id: number) => frames.delete(id));
const scrolled: HTMLElement[] = [];
dom.window.HTMLElement.prototype.scrollIntoView = function () { scrolled.push(this); };
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
dom.window.HTMLElement.prototype.scrollIntoView = () => {};
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { Tabs } = await import('../../ui/tabs');
const { default: SettingsPluginsTab } = await import('./SettingsPluginsTab');
const { OPEN_PLUGIN_VIEW_EVENT } = await import('../../../application/state/usePluginViewLifecycle');
const viewRequests: unknown[] = [];
dom.window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, (event) => viewRequests.push((event as CustomEvent).detail));
const plugin = { id: 'com.example.alpha', enabled: true, activeVersion: '1.0.0', archiveSha256: 'a'.repeat(64),
source: { id: 'local', kind: 'local', name: 'Local import', origin: null },
manifest: { manifestVersion: 1, id: 'com.example.alpha', name: 'Alpha', version: '1.0.0', publisher: 'example', engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'index.js' }, contributes: { settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application' }] } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } };
let savedValue = 'synthetic';
let rejectSave = false;
let installed = plugin;
let delayed = false;
const pending: (() => void)[] = [];
@@ -38,12 +40,18 @@ async function fixture() {
async getPluginLogStatus() { return { saveToDisk: false, verbose: false, droppedRecords: 0, writeErrors: 0, pendingBytes: 0, maxBytes: 3145728, retentionDays: 7 }; },
async getPluginContributions(query: NetcattyPluginContributionQuery) {
if (delayed && query.scopeIds) await new Promise<void>((resolve) => pending.push(resolve));
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: 'synthetic', visible: true, configured: true }] }] };
if (!installed.enabled) return { locale: 'en', plugins: [] };
return { locale: 'en', plugins: [{ id: plugin.id, version: '1.0.0', displayName: 'Alpha', commands: [], keybindings: [], menus: [], views: [{ id: 'com.example.alpha.page', title: 'Ordinary page', location: 'tab', visible: true }], settings: [{ id: 'com.example.alpha.setting', label: 'Alpha setting', control: 'text', scope: 'application', scopeId: 'application', value: savedValue, group: 'General', visible: true, configured: true }] }] };
},
async getPluginScopeCatalog() { return { workspace: [], host: [], session: [], device: [] }; },
async listPluginSources() { return []; },
async listPluginCatalog() { return { entries: [], source: 'builtin', error: null }; },
async getPluginPermissions() { return { grants: [], audit: [] }; },
async updatePluginSetting(_pluginId: string, _settingId: string, value: string) {
if (rejectSave) throw new Error('Could not save');
savedValue = value; return { restartRequired: false };
},
async resetPluginSetting() { savedValue = 'synthetic'; return { restartRequired: false }; },
} });
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 25));
@@ -51,99 +59,107 @@ async function fixture() {
await perform(() => root.render(<I18nProvider locale="en"><Tabs defaultValue="plugins"><SettingsPluginsTab /></Tabs></I18nProvider>));
const gear = document.querySelector<HTMLButtonElement>('button[aria-label="Settings for Alpha"]')!;
assert.ok(gear && !gear.disabled);
const flushFrames = async (count = 3) => {
for (let i = 0; i < count; i++) await act(async () => {
const current = [...frames.values()]; frames.clear();
for (const callback of current) callback(i * 16);
});
};
return {
gear, scrolled, perform, flushFrames,
section: () => document.querySelector<HTMLElement>('section[aria-labelledby="plugin-settings-com.example.alpha"]'),
gear, perform, viewRequests,
rejectSave: (value: boolean) => { rejectSave = value; },
section: () => document.querySelector<HTMLElement>('[data-plugin-settings-form]'),
open: async (slow = false) => { delayed = slow; await perform(() => { gear.focus(); gear.click(); }); },
release: async () => { delayed = false; await perform(() => { for (const resolve of pending.splice(0)) resolve(); }); },
selectTab: async (name: string) => {
const tab = [...document.querySelectorAll<HTMLElement>('[role=dialog] [role=tab]')].find((element) => element.textContent?.startsWith(name))!;
await perform(() => { tab.focus(); tab.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
},
close: async () => { await perform(() => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))); },
replaceOwner: async () => { installed = { ...plugin, source: { ...plugin.source, id: 'third-party:replacement', kind: 'third-party' } }; await perform(() => window.dispatchEvent(new Event('netcatty:plugin-packages-changed'))); },
disable: async () => { installed = { ...installed, enabled: false }; await perform(() => { for (const callback of [...listeners]) callback(); }); },
refresh: async () => { await perform(() => { for (const callback of [...listeners]) callback(); }); },
dispose: async () => {
await act(async () => root.unmount()); dom.window.close();
await act(async () => { root.unmount(); }); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
},
};
}
test('gear entry scrolls and focuses its settings once, after dialog autofocus', async () => {
test('gear opens a dedicated settings modal without management chrome and restores focus', async () => {
const f = await fixture();
try {
await f.open();
assert.notEqual(document.activeElement, f.section());
await f.flushFrames();
assert.deepEqual(f.scrolled, [f.section()]);
assert.equal(document.activeElement, f.section());
assert.ok(document.querySelector('[data-plugin-settings-dialog="com.example.alpha"]'));
assert.equal(document.querySelector('[data-plugin-detail]'), null);
assert.equal(document.querySelector('[role=dialog] [role=tablist]'), null);
const input = document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
input.focus();
await f.refresh(); await f.flushFrames();
assert.equal(document.activeElement, input, 'later contribution refresh cannot steal focus');
assert.equal(f.scrolled.length, 1);
input.focus(); await f.refresh();
assert.equal(document.activeElement, input, 'refresh cannot steal focus');
await f.close();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
assert.deepEqual(f.viewRequests, [], 'settings never open custom plugin pages');
} finally { await f.dispose(); }
});
test('gear entry waits for slow contributions before targeting the existing settings section', async () => {
test('settings modal saves across reopening, reports failures and resets', async () => {
const f = await fixture();
try {
await f.open(true); await f.flushFrames();
assert.equal(f.section(), null); assert.equal(f.scrolled.length, 0);
await f.release(); await f.flushFrames();
assert.equal(f.scrolled.length, 1); assert.equal(document.activeElement, f.section());
await f.open();
const section = f.section()!;
assert.equal(document.querySelector('[data-host-security-surface]'), null);
assert.equal(section.querySelector('h4')?.textContent, 'General');
assert.doesNotMatch(section.textContent!, /com\.example\.alpha|Ordinary page/);
assert.equal(section.querySelector('[data-plugin-remote-surface]'), null);
const input = () => document.querySelector<HTMLInputElement>('input[aria-label="Alpha setting"]')!;
const edit = async (value: string) => {
await f.perform(() => {
input().focus();
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input(), value);
input().dispatchEvent(new Event('input', { bubbles: true }));
input().blur();
});
};
await edit('Saved preference');
await f.close(); await f.open();
assert.equal(input().value, 'Saved preference');
f.rejectSave(true); await edit('Rejected preference');
assert.match(f.section()!.querySelector('[role=alert]')!.textContent!, /Could not save/);
f.rejectSave(false);
await f.close(); await f.open();
assert.equal(input().value, 'Saved preference', 'failed save did not replace the stored value');
await f.perform(() => [...f.section()!.querySelectorAll<HTMLButtonElement>('button')].find(button => button.getAttribute('aria-label') === 'Reset')!.click());
assert.equal(input().value, 'synthetic');
assert.deepEqual(f.viewRequests, [], 'settings never open custom plugin pages');
} finally { await f.dispose(); }
});
test('closing between animation frames cancels an already scheduled gear focus', async () => {
test('a slow settings response renders in the modal and never reopens a dismissed modal', async () => {
const f = await fixture();
try {
await f.open(); await f.flushFrames(1);
await f.close(); await f.flushFrames();
assert.equal(f.scrolled.length, 0);
await f.open(true);
assert.ok(document.querySelector('[role=dialog] [role=status]'));
await f.release();
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
await f.close(); await f.open(true); await f.close(); await f.release();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
} finally { await f.dispose(); }
});
for (const cancellation of ['tab', 'plugin', 'owner', 'close']) test(`pending gear entry cancels on ${cancellation} change`, async () => {
test('removing settings contributions closes the open modal', async () => {
const f = await fixture();
try {
await f.open(true);
if (cancellation === 'tab') await f.selectTab('Official');
if (cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
if (cancellation === 'owner') await f.replaceOwner();
if (cancellation === 'close') await f.close();
await f.release();
if (cancellation === 'tab') await f.selectTab('Installed');
if (cancellation === 'tab' || cancellation === 'plugin') await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.equal(f.scrolled.length, 0, 'late data or returning to the plugin cannot revive the cancelled intent');
if (cancellation === 'close') {
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, f.gear);
}
await f.open(); await f.disable();
assert.equal(document.querySelector('[data-plugin-settings-dialog]'), null);
} finally { await f.dispose(); }
});
test('normal Browse and detail entry retain their existing detail focus', async () => {
test('plugin details offer the same modal and Escape returns to the detail first', async () => {
const f = await fixture();
try {
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === 'Browse')!;
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Browse')!;
await f.perform(() => { browse.focus(); browse.click(); });
await f.perform(() => document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!.click());
await f.flushFrames();
assert.ok(f.section()); assert.equal(f.scrolled.length, 0);
assert.equal(document.activeElement, document.querySelector('[data-plugin-detail]'));
assert.equal(f.section(), null, 'settings are not embedded below management content');
const gear = document.querySelector<HTMLButtonElement>('[data-plugin-detail] button[aria-label="Settings for Alpha"]')!;
assert.ok(gear);
await f.perform(() => { gear.focus(); gear.click(); });
assert.ok(f.section());
await f.close();
assert.equal(f.section(), null);
assert.ok(document.querySelector('[data-plugin-detail]'));
assert.equal(document.activeElement, gear);
await f.close(); assert.equal(document.activeElement, browse);
} finally { await f.dispose(); }
});
@@ -47,7 +47,7 @@ test('landing rows expose real sources/status/settings and quick toggles preserv
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.5'), 'title, metadata and description have 6px vertical gaps');
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');
@@ -1,5 +1,5 @@
import React, { useCallback, useMemo, useState } from 'react';
import { RefreshCw, Settings2 } from 'lucide-react';
import { RefreshCw, Settings } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import type { usePluginUpdateCheck } from '../../../application/state/usePluginUpdateCheck';
import type { usePluginManager } from '../../../application/state/usePluginManager';
@@ -62,20 +62,21 @@ export function PluginSettingsLanding({ manager, settingsAvailable, settingsErro
const name = nameOf(plugin);
const description = localized(plugin.manifest?.description);
const status = statusOf(plugin);
const hasError = manager.statusKnown && ['error', 'quarantined'].includes(plugin.runtime.status);
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} description={<div className="flex min-w-0 flex-col gap-1.5 text-xs text-muted-foreground">
<span className="flex min-w-0 items-baseline gap-2 text-sm font-medium text-foreground">
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 />
{status && <span role="status" className="block break-words">{status}</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 items-center gap-3">
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
<div className="flex shrink-0 items-center gap-2">
{declaredSettings && <Button className="h-8 w-8 rounded-full text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings size={15} /></Button>}
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
</div>
</SettingRow>;
@@ -5,8 +5,8 @@ import { createRequire } from 'node:module';
import { JSDOM } from 'jsdom';
const require = createRequire(import.meta.url);
test('real Settings landing leaves views visible while idle; log and Ask dialogs suspend and restore them', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div><div id="native-view-mount"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
test('real Settings logging and Ask dialogs open, nest and dismiss correctly', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
@@ -22,7 +22,6 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginSettingsLanding } = await import('./PluginSettingsLanding');
const { PluginPermissionHost } = await import('../../plugins/PluginPermissionHost');
const { observePluginHostSecurityVisibility } = await import('../../../application/state/pluginHostSecurityVisibility');
const { PluginPermissionDecisionHost } = require('../../../electron/plugins/permissionDecisionHost.cjs');
type Prompt = NetcattyPluginPermissionPrompt | { requestId: string; closed: true };
const listeners = new Set<(event: Prompt) => void>();
@@ -39,9 +38,6 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
} });
const manager = { plugins: [], available: true, statusKnown: true, busy: false, error: null, credentialStorageAvailable: true,
async refresh() {}, async setEnabled() {}, async importPackage() {}, async uninstall() {} };
let visible = true;
const transitions: boolean[] = [];
const stopVisibility = observePluginHostSecurityVisibility(document.getElementById('native-view-mount')!, (next) => { visible = next; transitions.push(next); });
const root = createRoot(document.getElementById('root')!);
const button = (label: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((element) => element.textContent === label)!;
const flushVisibility = () => new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
@@ -50,24 +46,19 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.ok(button('Configure'), 'real idle logging row is present');
assert.equal(visible, true, 'idle Settings landing must not hide the native child');
assert.deepEqual(transitions, []);
await act(async () => button('Configure').click());
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'actual logging security dialog must hide the child');
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[0].click());
await act(async () => document.querySelectorAll<HTMLButtonElement>('[role="switch"]')[1].click());
await flushVisibility();
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
assert.equal(visible, false, 'the verbose security confirmation must also keep the child hidden');
await act(async () => button('Cancel').click());
await flushVisibility();
assert.equal(visible, false, 'cancelling verbose returns to the protected logging dialog');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
await act(async () => button('Close').click());
await flushVisibility();
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(visible, true, 'closing logs must restore the child while its idle row remains');
let pending!: Promise<{ decision: string }>;
await act(async () => {
pending = host.request({ requestId: 'settings-visibility-ask', pluginId: 'com.example.settings-visibility', pluginName: 'Settings visibility fixture', pluginVersion: '1.0.0',
@@ -75,14 +66,12 @@ test('real Settings landing leaves views visible while idle; log and Ask dialogs
});
await flushVisibility();
assert.ok(document.querySelector('[role="dialog"][data-host-security-surface]'));
assert.equal(visible, false, 'Ask must still hide the child above the real landing');
await act(async () => { button('Deny').click(); await pending; });
await flushVisibility();
assert.equal((await pending).decision, 'deny');
assert.equal(visible, true, 'dismissing Ask must restore the same child');
assert.deepEqual(transitions, [false, true, false, true]);
assert.equal(document.querySelector('[role="dialog"]'), null);
} finally {
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); dom.window.close();
await act(async () => root.unmount()); host.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('uninstall requires a visible confirmation, reports failures, and removes the installed card after success', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'Event', 'MouseEvent', 'KeyboardEvent', 'MutationObserver', 'DocumentFragment', 'CustomEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { usePluginManager } = await import('../../../application/state/usePluginManager');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const { PluginPackageManager } = await import('./PluginPackageManager');
const plugin = {
id: 'com.example.disposable', enabled: false, activeVersion: '1.0.0',
manifest: { manifestVersion: 1, id: 'com.example.disposable', name: 'Disposable', version: '1.0.0', publisher: 'Example', engines: { netcatty: '*', api: '*' }, main: { browser: 'index.js' } },
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
} as NetcattyInstalledPlugin;
let installed = [plugin];
const removed: string[] = [];
let finish!: () => void;
let fail!: (error: Error) => void;
Object.assign(dom.window, { netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async listPlugins() { return installed; },
async getPluginPermissions() { return { policies: [], grants: [], audit: [] }; },
async uninstallPlugin(id: string) {
removed.push(id);
await new Promise<void>((resolve, reject) => { finish = resolve; fail = reject; });
installed = installed.filter((entry) => entry.id !== id);
},
} });
function Fixture() {
const manager = usePluginManager();
return <PluginManagementDialog open trigger={null}><PluginPackageManager manager={manager} initialSelectedId={plugin.id} /></PluginManagementDialog>;
}
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 30));
const confirmation = () => document.querySelector<HTMLElement>('[data-plugin-uninstall-confirm]');
const button = (label: string, within: ParentNode = document) => [...within.querySelectorAll<HTMLButtonElement>('button')].find((item) => item.textContent === label)!;
try {
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
const trigger = button('Uninstall');
await act(async () => { trigger.click(); await settle(); });
assert.ok(confirmation(), 'confirmation is a separate dialog, not below the scroll fold');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
assert.deepEqual(removed, [], 'opening the confirmation never deletes the plugin');
const checkbox = confirmation()!.querySelector<HTMLInputElement>('input[type="checkbox"]')!;
assert.equal(checkbox.checked, false);
await act(async () => { checkbox.click(); button('Cancel', confirmation()!).click(); await settle(); });
assert.equal(confirmation(), null);
assert.deepEqual(removed, []);
assert.equal(document.activeElement, trigger);
await act(async () => { trigger.click(); await settle(); });
assert.equal(confirmation()!.querySelector<HTMLInputElement>('input')!.checked, false, 'private data clearing is reset for a new confirmation');
await act(async () => { button('Uninstall', confirmation()!).click(); await settle(); });
assert.deepEqual(removed, [plugin.id]);
assert.equal(button('Uninstall', confirmation()!).disabled, true);
await act(async () => { fail(new Error('Synthetic removal failed')); await settle(); });
assert.match(confirmation()!.querySelector('[role="alert"]')!.textContent!, /Synthetic removal failed/);
assert.equal(installed.length, 1);
await act(async () => { button('Uninstall', confirmation()!).click(); await settle(); });
await act(async () => { finish(); await settle(); });
assert.equal(confirmation(), null);
assert.equal(installed.length, 0);
assert.equal(document.querySelector('article'), null, 'the removed plugin no longer has an installed card or detail');
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1, 'the plugin manager remains open');
} finally {
await act(async () => { root.unmount(); await settle(); });
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -48,18 +48,21 @@ test('landing settings entry targets the declared plugin and a manager tab round
assert.ok(settings);
assert.equal(settings.disabled, false);
await act(async () => { settings.focus(); settings.click(); await settle(); });
assert.ok(document.querySelector('[data-plugin-detail]'));
assert.ok(document.querySelector('[data-plugin-settings-dialog]'));
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'));
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
const browse = [...document.querySelectorAll<HTMLButtonElement>('button')].find(button => button.textContent === 'Browse')!;
await act(async () => { browse.focus(); browse.click(); await settle(); });
await selectTab('Official');
await selectTab('Installed');
assert.equal(document.querySelector('[data-plugin-detail]'), null, 'returning to Installed shows the grid, not a stale deep-link detail');
assert.equal(document.querySelector('input[aria-label="Alpha setting"]'), null);
const card = document.querySelector<HTMLButtonElement>('[data-plugin-card="com.example.alpha"]')!;
await act(async () => { card.click(); await settle(); });
assert.ok(document.querySelector('input[aria-label="Alpha setting"]'), 'choosing the plugin again restores its real settings');
assert.ok(document.querySelector('[data-plugin-detail] button[aria-label="Settings for Alpha"]'), 'details offer the same settings entry');
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.activeElement, settings);
assert.equal(document.activeElement, browse);
const toggle = document.querySelector<HTMLButtonElement>('button[aria-label="Disable Alpha"]')!;
await act(async () => { toggle.click(); await settle(); });
assert.match(document.querySelector('[role=alert]')!.textContent!, /Synthetic management failure/);
@@ -4,7 +4,7 @@ import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginSettingField } from './SettingsPluginsTab';
import { PluginSettingField } from '../../plugins/PluginSettingsForm';
test('plugin setting cards retain localized descriptions alongside structured controls', () => {
const setting = {
+14 -387
View File
@@ -7,342 +7,25 @@ import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import { PluginSettingsLanding } from './PluginSettingsLanding';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { SettingsAnchor, SettingsTabContent } from '../settings-ui';
import { requestOpenPluginView } from '../../plugins/PluginContributionHost';
import { parsePluginStructuredSettingValue } from './pluginSettingValues';
import { PluginStructuredSettingEditor } from './PluginStructuredSettingEditor';
import { useAvailableFonts } from '../../../application/state/fontStore';
import { TerminalFontSelect } from '../TerminalFontSelect';
import {
resolvePluginSettingScopeSelection,
usePluginSettingScopeCatalog,
} from '../../../application/state/usePluginSettingScopeCatalog';
import { PluginContributionIcon } from '../../plugins/PluginContributionIcon';
import { PluginSettingsEntry, PluginSettingsDialog } from '../../plugins/PluginSettingsEntry';
export function PluginSettingField({
pluginId,
setting,
updateSetting,
resetSetting,
selectSettingPath,
availableFonts,
}: {
pluginId: string;
setting: NetcattyPluginSettingContribution;
updateSetting: ReturnType<typeof usePluginContributions>['updateSetting'];
resetSetting: ReturnType<typeof usePluginContributions>['resetSetting'];
selectSettingPath: ReturnType<typeof usePluginContributions>['selectSettingPath'];
availableFonts: ReturnType<typeof useAvailableFonts>;
}) {
const { t } = useI18n();
const [value, setValue] = useState<unknown>(setting.secret ? '' : setting.value ?? '');
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const itemLabel = (key: 'settings.plugins.moveItemUp' | 'settings.plugins.moveItemDown', index: number) => (
t(key).replace('{label}', setting.label).replace('{index}', String(index + 1))
);
useEffect(() => {
setValue(setting.secret ? '' : setting.value ?? '');
}, [setting.secret, setting.value]);
const save = async (nextValue: unknown) => {
setSaving(true);
setError(null);
try {
await updateSetting(pluginId, setting.id, nextValue, setting.scopeId ?? undefined);
if (setting.secret) setValue('');
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
} finally {
setSaving(false);
}
};
const control = (() => {
if (setting.scopeId == null) {
return <div className="text-xs text-muted-foreground">{t('settings.plugins.scopeContext').replace('{scope}', setting.scope)}</div>;
}
if (setting.control === 'switch') {
return (
<input
aria-label={setting.label}
type="checkbox"
checked={Boolean(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.checked); void save(event.target.checked); }}
className="h-4 w-4 accent-primary"
/>
);
}
if (setting.control === 'radio') {
return (
<fieldset className="space-y-2" disabled={saving}>
<legend className="sr-only">{setting.label}</legend>
{setting.options?.map((option) => (
<label key={option.value} className="flex items-start gap-2 text-sm">
<input
type="radio"
name={setting.id}
value={option.value}
checked={value === option.value}
onChange={() => { setValue(option.value); void save(option.value); }}
className="mt-0.5 accent-primary"
/>
<span>{option.label}{option.description && <span className="block text-xs text-muted-foreground">{option.description}</span>}</span>
</label>
))}
</fieldset>
);
}
if (setting.control === 'select') {
return (
<select
aria-label={setting.label}
value={String(value)}
disabled={saving}
onChange={(event) => { setValue(event.target.value); void save(event.target.value); }}
className="h-9 min-w-52 rounded-md border border-input bg-background px-3 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'multiselect') {
const selected = Array.isArray(value) ? value.map(String) : [];
return (
<select
aria-label={setting.label}
multiple
value={selected}
disabled={saving}
onChange={(event) => {
const next = [...event.currentTarget.selectedOptions].map((option) => option.value);
setValue(next);
void save(next);
}}
className="min-h-24 min-w-52 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{setting.options?.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
);
}
if (setting.control === 'textarea') {
return (
<textarea
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => void save(event.currentTarget.value)}
className="min-h-24 w-full max-w-xl rounded-md border border-input bg-background px-3 py-2 text-sm"
/>
);
}
if (setting.control === 'number' || setting.control === 'slider') {
return (
<Input
aria-label={setting.label}
type={setting.control === 'slider' ? 'range' : 'number'}
value={typeof value === 'number' ? value : Number(value) || 0}
min={setting.minimum}
max={setting.maximum}
step={setting.step}
disabled={saving}
onChange={(event) => setValue(Number(event.target.value))}
onBlur={(event) => void save(Number(event.currentTarget.value))}
className="max-w-sm"
/>
);
}
if (setting.control === 'list' || setting.control === 'table') {
return (
<PluginStructuredSettingEditor
setting={setting}
value={typeof value === 'string' ? parsePluginStructuredSettingValue(value) : value}
disabled={saving}
onChange={setValue}
onCommit={(next) => void save(next)}
labels={{
add: t('settings.plugins.addItem'),
remove: t('settings.plugins.removeItem'),
moveUp: (index) => itemLabel('settings.plugins.moveItemUp', index),
moveDown: (index) => itemLabel('settings.plugins.moveItemDown', index),
}}
/>
);
}
if (setting.control === 'font') {
return (
<TerminalFontSelect
value={String(value)}
fonts={availableFonts}
disabled={saving}
onChange={(next) => { setValue(next); void save(next); }}
className="w-full max-w-xl"
ariaLabel={setting.label}
/>
);
}
if (setting.control === 'file' || setting.control === 'directory') {
return (
<div className="flex max-w-xl gap-2">
<Input aria-label={setting.label} value={String(value)} readOnly disabled={saving} className="min-w-0 flex-1" />
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => void selectSettingPath(setting.control as 'file' | 'directory', setting.label, String(value || '')).then((selected) => {
if (!selected) return;
setValue(selected);
void save(selected);
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))}
>
<FolderOpen size={14} className="mr-2" /> {t('settings.plugins.browse')}
</Button>
</div>
);
}
if (setting.control === 'keybinding') {
return (
<Input
aria-label={setting.label}
value={String(value)}
placeholder={setting.placeholder ?? t('settings.plugins.pressKeybinding')}
readOnly
disabled={saving}
onKeyDown={(event) => {
event.preventDefault();
const key = normalizePluginKeyboardEvent(event.nativeEvent);
if (!key) return;
setValue(key);
void save(key);
}}
className="max-w-xl"
/>
);
}
return (
<Input
aria-label={setting.label}
type={setting.secret || setting.control === 'password' ? 'password' : setting.control === 'color' ? 'color' : 'text'}
value={String(value)}
placeholder={setting.secret && setting.configured ? t('settings.plugins.configuredReplacement') : setting.placeholder}
disabled={saving}
onChange={(event) => setValue(event.target.value)}
onBlur={(event) => {
const next = event.currentTarget.value;
if (!setting.secret || next.length > 0) void save(next);
}}
className="max-w-xl"
/>
);
})();
return (
<div className="rounded-lg border border-border/70 bg-background p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<label className="text-sm font-medium">{setting.label}</label>
{setting.description && (
<p className="mt-1 max-w-2xl text-xs leading-relaxed text-muted-foreground">{setting.description}</p>
)}
<p className="mt-1 font-mono text-[10px] text-muted-foreground">{setting.id} · {setting.scope}</p>
</div>
{setting.restartRequired && <span className="rounded bg-amber-500/15 px-2 py-1 text-[10px] text-amber-700 dark:text-amber-300">{t('settings.plugins.restartRequired')}</span>}
</div>
{control}
<div className="flex items-center gap-2">
{!setting.required && setting.scopeId != null && (
<Button
type="button"
variant="outline"
size="sm"
disabled={saving}
onClick={() => {
setSaving(true);
setError(null);
void resetSetting(pluginId, setting.id, setting.scopeId ?? undefined)
.catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)))
.finally(() => setSaving(false));
}}
>
{t('common.reset')}
</Button>
)}
{saving && <span className="text-xs text-muted-foreground">{t('settings.plugins.saving')}</span>}
{setting.secret && setting.configured && <span className="text-xs text-emerald-600">{t('settings.plugins.storedSecurely')}</span>}
{error && <span role="alert" className="text-xs text-destructive">{error}</span>}
</div>
</div>
);
}
function PluginManagementContent({ handoff, manager, initialSelectedId, active }: {
handoff: (action: () => void) => void;
function PluginManagementContent({ manager }: {
manager: ReturnType<typeof usePluginManager>;
initialSelectedId: string | null;
active: boolean;
}) {
const { t } = useI18n();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(initialSelectedId);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
useEffect(() => {
setScopeIds((current) => resolvePluginSettingScopeSelection(scopeCatalog, current));
}, [scopeCatalog]);
const query = useMemo<NetcattyPluginContributionQuery>(() => ({
context: { 'netcatty.surface': 'settings' },
scopeIds,
}), [scopeIds]);
const contributions = usePluginContributions(query);
const settingsSection = useRef<HTMLElement | null>(null);
const installed = manager.plugins.find((plugin) => plugin.id === selectedInstalled);
const ownerKey = JSON.stringify(installed && [installed.id, installed.activeVersion, installed.archiveSha256,
installed.source?.id, installed.source?.kind, installed.source?.origin]);
const gearEntry = useRef(initialSelectedId ? { pluginId: initialSelectedId, ownerKey } : null);
useLayoutEffect(() => {
const intent = gearEntry.current;
if (!intent) return;
if (!active || pluginView !== 'installed' || selectedInstalled !== intent.pluginId || ownerKey !== intent.ownerKey) {
gearEntry.current = null;
return;
}
const section = settingsSection.current;
if (contributions.loading || contributions.error || !contributions.available || !section) return;
// Let dialog autofocus settle first, including when contributions arrive late.
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
if (gearEntry.current !== intent || !section.isConnected) return;
gearEntry.current = null;
section.scrollIntoView({ block: 'start' });
section.focus({ preventScroll: true });
});
});
return () => cancelAnimationFrame(frame);
}, [active, pluginView, selectedInstalled, ownerKey, contributions.loading, contributions.error, contributions.available, contributions.snapshot]);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application')),
[contributions.snapshot.plugins, selectedInstalled]);
return (
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { gearEntry.current = null; setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
@@ -358,65 +41,7 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { gearEntry.current = null; setSelectedInstalled(id); }} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
<label key={kind} className="space-y-1 text-xs font-medium">
<span>{t('settings.plugins.scopeTarget').replace('{scope}', kind)}</span>
<select
value={scopeIds[kind] ?? ''}
onChange={(event) => setScopeIds((current) => ({ ...current, [kind]: event.target.value || undefined }))}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm font-normal"
>
{!scopeCatalog[kind].length && <option value="">{t('settings.plugins.noScopeTargets')}</option>}
{scopeCatalog[kind].map((entry) => <option key={entry.id} value={entry.id}>{entry.label}</option>)}
</select>
</label>
))}
</section>
)}
{contributions.loading && <p className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{contributions.error && <p role="alert" className="text-sm text-destructive">{contributions.error.message}</p>}
{contributions.snapshot.plugins.filter((plugin) => plugin.id === selectedInstalled).map((plugin) => {
const settings = plugin.settings.filter((setting) => setting.visible);
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
return (
<section key={plugin.id} ref={settingsSection} tabIndex={-1} className="space-y-3 scroll-mt-4 focus:outline-none" aria-labelledby={`plugin-settings-${plugin.id}`}>
<div>
<h3 id={`plugin-settings-${plugin.id}`} className="text-base font-semibold">{plugin.displayName}</h3>
{plugin.description && <p className="text-xs text-muted-foreground">{plugin.description}</p>}
</div>
{settings.map((setting) => (
<PluginSettingField
key={setting.id}
pluginId={plugin.id}
setting={setting}
updateSetting={contributions.updateSetting}
resetSetting={contributions.resetSetting}
selectSettingPath={contributions.selectSettingPath}
availableFonts={availableFonts}
/>
))}
{views.map((view) => (
<div key={view.id} className="flex items-center justify-between rounded-lg border border-border/70 bg-background p-4">
<div className="flex min-w-0 items-center gap-3">
<PluginContributionIcon pluginId={plugin.id} icon={view.icon} size={18} className="shrink-0" />
<div className="min-w-0">
<div className="text-sm font-medium">{view.title}</div>
<div className="font-mono text-[10px] text-muted-foreground">{view.id}</div>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => handoff(() => requestOpenPluginView({
viewId: view.id,
context: { 'netcatty.surface': 'settings' },
}))}>{t('settings.plugins.openView')}</Button>
</div>
))}
</section>
);
})}
<PluginPackageManager manager={manager} initialSelectedId={selectedInstalled} onSelectionChange={(id) => { setSelectedInstalled(id); }} detailActions={selectedInstalled && <PluginSettingsEntry key={`settings:${selectedInstalled}`} pluginId={selectedInstalled} />} />
</TabsContent>
</Tabs>
</div>
@@ -425,27 +50,29 @@ function PluginManagementContent({ handoff, manager, initialSelectedId, active }
export default function SettingsPluginsTab() {
const { locale } = useI18n();
const manager = usePluginManager();
const updates = usePluginUpdateCheck(manager.plugins);
const [open, setOpen] = useState(false);
const [initialSelectedId, setInitialSelectedId] = useState<string | null>(null);
const [settingsPluginId, setSettingsPluginId] = useState<string | null>(null);
const opener = useRef<HTMLElement | null>(null);
const managerButton = useRef<HTMLButtonElement | null>(null);
const contributions = usePluginContributions({ context: { 'netcatty.surface': 'settings' } });
const contributions = usePluginContributions({ locale, context: { 'netcatty.surface': 'settings' } });
const settingsAvailable = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.settings.some((setting) => setting.visible) || plugin.views.some((view) => view.location === 'settings' && view.visible))
.filter((plugin) => plugin.settings.some((setting) => setting.visible))
.map((plugin) => plugin.id)), [contributions.snapshot]);
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="space-y-6">
<PluginSettingsLanding manager={manager} updates={updates} managerButtonRef={managerButton} settingsAvailable={settingsAvailable} settingsError={contributions.error?.message} onManage={(pluginId, element) => {
opener.current = element;
setInitialSelectedId(pluginId);
setOpen(true);
if (pluginId) setSettingsPluginId(pluginId);
else setOpen(true);
}} />
<PluginManagementDialog open={open} onOpenChange={setOpen} trigger={null} restoreFocusTo={opener} fallbackFocusTo={managerButton}>
{(handoff) => <PluginManagementContent handoff={handoff} manager={manager} initialSelectedId={initialSelectedId} active={open} />}
<PluginManagementContent manager={manager} />
</PluginManagementDialog>
{settingsPluginId && <PluginSettingsDialog key={settingsPluginId} pluginId={settingsPluginId} onClose={() => setSettingsPluginId(null)} restoreFocusTo={opener} />}
</SettingsAnchor>
</SettingsTabContent>
);
+1 -1
View File
@@ -1159,7 +1159,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
)}
</ToolbarCustomizeContextMenu>
{!compactToolbar && <PluginTerminalSidebarLauncher sessionId={sessionId} />}
<PluginTerminalSidebarLauncher sessionId={sessionId} buttonClassName={buttonBase} />
{pluginToolbarMenus.map((menu) => (
<Tooltip key={menu.id}>
<TooltipTrigger asChild>
+396
View File
@@ -1,4 +1,5 @@
import test from "node:test";
import * as pty from "node-pty";
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import {
@@ -17,10 +18,22 @@ import {
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { isIdleShellReadyForCommandInjection } from "../../domain/terminalCommandInjectionReady";
import {
isTerminalReadyForCommandInjection,
registerTerminalCommandInjectionReadyReader,
} from "./runtime/terminalCommandInjectionReadyRegistry";
import {
OSC7_MARKER,
OSC7_SETUP_OTHER_USER_MARKER,
OSC7_SETUP_STAGED_MARKER,
OSC7_PTY_SHELL_MARKER,
OSC7_PTY_ACK_MARKER,
OSC7_PTY_DONE_MARKER,
OSC7_PTY_FAILED_MARKER,
buildOsc7PtyShellProbeCommand,
buildOsc7PtySetupCommands,
buildOsc7ReloadCommand,
buildOsc7SetupCommand,
buildOsc7SetupExecCommand,
@@ -30,6 +43,8 @@ import {
parseOsc7SetupStagedPath,
runOsc7SetupAction,
shouldOfferOsc7SetupAction,
waitForOsc7SessionOutput,
type RunOsc7SetupActionOptions,
} from "./osc7Setup";
const runSetup = (env: NodeJS.ProcessEnv) => {
@@ -193,6 +208,229 @@ test("runOsc7SetupAction stages the script and types a short runner for user-swi
assert.deepEqual(localData, []);
});
// Model separate exec/PTY identities (e.g. Koko) with live output observers.
// Echo input first, then deliver acknowledgements in separate chunks.
const createExit4PtyHarness = (shell: string | null = "bash", completion: "done" | "failed" | null = "done") => {
const writes: string[] = [];
const setupCommands: string[] = [];
const timeouts: number[] = [];
let onData: ((chunk: string) => void) | undefined;
let disposed = 0;
let ready = true;
const options: RunOsc7SetupActionOptions = {
status: "connected",
sessionId: "pty-session",
setupCommand: "exec-only-setup",
setupOsc7Tracking: async (_id, command) => {
setupCommands.push(command);
return { success: false, code: 4, error: "exec identity mismatch" };
},
isTerminalReadyForCommandInjection: () => ready,
waitForSessionOutput: (id, predicate, timeout) => {
timeouts.push(timeout);
return waitForOsc7SessionOutput((observedId, callback) => {
assert.equal(observedId, options.sessionId);
onData = callback;
return () => { onData = undefined; disposed += 1; };
}, id, predicate, 10);
},
writeToSession: (id, command, writeOptions) => {
assert.equal(id, options.sessionId);
assert.equal(writeOptions?.automated, true);
assert.deepEqual(writeOptions?.logRewrite, { sentCommand: command, displayCommand: "" });
assert.ok(onData, "subscribe before writing so fast replies cannot be lost");
writes.push(command);
onData(`prompt> ${command.replace(/\r$/, "\r\n")}`);
assert.ok(onData, "input echo must not satisfy the output predicate");
const final = command.includes("base64 -d");
const marker = writes.length === 1 ? OSC7_PTY_SHELL_MARKER
: !final ? OSC7_PTY_ACK_MARKER
: completion === "failed" ? OSC7_PTY_FAILED_MARKER : OSC7_PTY_DONE_MARKER;
const value = writes.length === 1 ? shell : !final ? String(writes.length - 2) : completion && shell;
if (value) {
onData(`\r\n${marker.slice(0, 12)}`);
onData?.(`${marker.slice(12)}${value}\r`);
assert.ok(onData, "wait for the complete line, not a partial shell name");
onData?.("\n");
}
},
};
return {
options, writes, setupCommands, timeouts,
setReady: (value: boolean) => { ready = value; },
disposed: () => disposed,
};
};
test("exit 4 probes the foreground shell and waits for chunked installation DONE, without exec staging", async () => {
for (const shell of ["bash", "zsh"]) {
const h = createExit4PtyHarness(shell);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, true);
assert.equal(result.sentToTerminal, true);
assert.equal(result.error, undefined);
assert.deepEqual(h.setupCommands, ["exec-only-setup"]);
assert.deepEqual(h.writes, [buildOsc7PtyShellProbeCommand(), ...buildOsc7PtySetupCommands(shell as "bash" | "zsh")]);
assert.match(h.writes[0], /ZSH_VERSION.*BASH_VERSION/);
assert.match(h.writes[1], /mktemp/);
assert.match(h.writes.at(-1)!, new RegExp(`NETCATTY_OSC7_FORCE_SHELL=${shell}`));
for (const command of h.writes) assert.ok(Buffer.byteLength(command) < 3000);
assert.deepEqual(h.timeouts, h.writes.map((_, i) => i === h.writes.length - 1 ? 20000 : 5000));
assert.equal(h.disposed(), h.writes.length);
}
});
test("exit-4 stderr messages also select the dedicated PTY path", async () => {
for (const stderr of [
"Netcatty OSC 7 setup: could not identify the active terminal shell",
"Netcatty OSC 7 setup: active terminal shell did not match the current tab",
]) {
const h = createExit4PtyHarness();
h.options.setupOsc7Tracking = async () => ({ success: false, stderr: `${stderr}\n` });
assert.equal((await runOsc7SetupAction(h.options)).success, true);
assert.equal(h.writes.length, 1 + buildOsc7PtySetupCommands("bash").length);
}
});
test("exit 4 fails closed with an unsafe prompt or missing safety callbacks", async () => {
for (const missing of ["unsafe", "isTerminalReadyForCommandInjection", "waitForSessionOutput"] as const) {
const h = createExit4PtyHarness();
if (missing === "unsafe") h.setReady(false);
else delete h.options[missing];
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.match(result.error || "", /idle bash\/zsh prompt.*empty input/);
assert.deepEqual(h.writes, []);
assert.deepEqual(h.timeouts, []);
}
});
test("exit 4 blocks recalled input, editors, alt-screen and sensitive prompts with an empty command buffer", async () => {
for (const unsafe of [
{ userInputLength: 12 }, // ESC-prefixed history recall need not enter commandBufferRef.
{ isAtPrompt: false },
{ alternateScreenActive: true },
{ sensitiveInputActive: true },
{ hasLiveTerminal: false },
]) {
const h = createExit4PtyHarness();
const unregister = registerTerminalCommandInjectionReadyReader(h.options.sessionId, () =>
isIdleShellReadyForCommandInjection({
hasLiveTerminal: true, isAtPrompt: true, userInputLength: 0,
pendingTypedInputLength: 0, alternateScreenActive: false,
sensitiveInputActive: false, ...unsafe,
}));
h.options.isTerminalReadyForCommandInjection = () => isTerminalReadyForCommandInjection(h.options.sessionId);
try {
assert.equal((await runOsc7SetupAction(h.options)).success, false);
assert.deepEqual(h.writes, []);
} finally {
unregister();
}
assert.equal(isTerminalReadyForCommandInjection(h.options.sessionId), false);
}
});
test("Terminal wires the live command-injection readiness registry into OSC7 setup", () => {
const source = readFileSync(new URL("../Terminal.tsx", import.meta.url), "utf8");
assert.match(source, /runOsc7SetupAction\(\{[^]*?isTerminalReadyForCommandInjection: \(\) => isTerminalReadyForCommandInjection\(sessionId\)/);
});
test("exit 4 refuses installation after probe timeout, unknown shell, or unsupported fish", async () => {
for (const shell of [null, "unknown", "fish"]) {
const h = createExit4PtyHarness(shell);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.match(result.error || "", /idle bash\/zsh prompt.*OSC 7 manually/);
assert.equal(h.writes.length, 1);
assert.equal(h.disposed(), 1);
}
});
test("exit 4 rechecks prompt readiness after the probe and between payload chunks", async () => {
for (const stopAfter of [1, 3]) {
const h = createExit4PtyHarness();
const write = h.options.writeToSession;
h.options.writeToSession = (...args) => {
write(...args);
if (h.writes.length === stopAfter) h.setReady(false);
};
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.equal(h.writes.length, stopAfter);
}
});
test("exit 4 does not claim success when installation times out or reports failure", async () => {
for (const completion of [null, "failed"] as const) {
const h = createExit4PtyHarness("bash", completion);
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.notEqual(result.sentToTerminal, true);
assert.match(result.error || "", /failed or did not finish/);
assert.equal(h.writes.length, 1 + buildOsc7PtySetupCommands("bash").length);
assert.equal(h.disposed(), h.writes.length);
}
});
test("exit 4 stops on a missing, stale or failed chunk acknowledgement", async () => {
for (const reply of [null, `${OSC7_PTY_ACK_MARKER}0`, `${OSC7_PTY_FAILED_MARKER}bash`]) {
const h = createExit4PtyHarness();
let count = 0;
let observer: ((chunk: string) => void) | undefined;
h.options.waitForSessionOutput = (id, predicate) => waitForOsc7SessionOutput((_id, callback) => {
observer = callback;
return () => { observer = undefined; };
}, id, predicate, 10);
h.options.writeToSession = (id, command, options) => {
count += 1;
if (count === 3) {
observer?.(`prompt> ${command}\r\n`);
if (reply) observer?.(`\r\n${reply}\r\n`);
} else {
observer?.(`prompt> ${command}\r\n`);
observer?.(`\r\n${count === 1 ? OSC7_PTY_SHELL_MARKER + "bash" : OSC7_PTY_ACK_MARKER + "0"}\r\n`);
}
assert.equal(id, h.options.sessionId);
assert.equal(options?.automated, true);
};
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.equal(count, 3, "no later chunk or final installer after a failed ack");
assert.match(result.error || "", /failed or did not finish/);
}
});
test("unrelated exec failures never select the exit-4 PTY transport", async () => {
for (const code of [1, 2, 3, 5]) {
const h = createExit4PtyHarness();
h.options.setupOsc7Tracking = async () => ({ success: false, code, stderr: "unrelated failure" });
const result = await runOsc7SetupAction(h.options);
assert.equal(result.success, false);
assert.deepEqual(h.writes, []);
assert.deepEqual(h.timeouts, []);
}
});
test("waitForOsc7SessionOutput disposes even if subscription immediately delivers output", async () => {
let disposed = false;
const result = await waitForOsc7SessionOutput((_id, callback) => {
callback("done");
return () => { disposed = true; };
}, "session", (output) => output === "done", 100);
assert.equal(result, "done");
assert.equal(disposed, true);
});
test("exit 4 sends no probe when the output subscription is unavailable", async () => {
const h = createExit4PtyHarness();
h.options.waitForSessionOutput = (id, predicate, timeout) => waitForOsc7SessionOutput(() => {
throw new Error("Session output unavailable");
}, id, predicate, timeout);
await assert.rejects(runOsc7SetupAction(h.options), /Session output unavailable/);
assert.deepEqual(h.writes, []);
});
test("runOsc7SetupAction fails when the setup script cannot be staged", async () => {
const writes: string[] = [];
let calls = 0;
@@ -776,6 +1014,164 @@ const withStagedSetupScript = (fn: (scriptPath: string) => void) => {
}
};
test("PTY builders chunk the exact shared script into lines below the canonical limit", () => {
for (const shell of ["bash", "zsh"] as const) {
const commands = buildOsc7PtySetupCommands(shell);
assert.ok(commands.length > 3);
for (const command of commands) {
assert.ok(command.startsWith(" "));
assert.ok(command.endsWith("\r"));
assert.doesNotMatch(command.slice(0, -1), /[\r\n]/);
assert.ok(Buffer.byteLength(command) < 3000);
}
assert.match(commands[0], /mktemp/);
assert.match(commands.at(-1)!, new RegExp(`NETCATTY_OSC7_FORCE_SHELL=${shell}`));
assert.doesNotMatch(commands.join(""), /NETCATTY_OSC7_EXPECTED_CWD/);
const payload = commands.flatMap((command) => {
const match = command.match(/printf '%s' '([A-Za-z0-9+/=]+)'/);
return match ? [match[1]] : [];
}).join("");
const script = Buffer.from(payload, "base64").toString("utf8");
assert.ok(buildOsc7SetupExecCommand().includes(quoteShellArg(script)), "reuse exact POSIX setup script");
assert.match(script, /netcatty-osc7-version: 2/);
}
});
test("exit-4 transport installs through a real canonical Bash PTY without exceeding its line limit", { timeout: 30000 }, async () => {
const home = mkdtempSync(join(tmpdir(), "netcatty-osc7-canonical-"));
const prompt = "__NETCATTY_TEST_PROMPT__> ";
const terminal = pty.spawn("/bin/bash", ["--noprofile", "--norc", "--noediting", "-i"], {
name: "xterm", cols: 120, rows: 24,
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", PS1: prompt, PS2: "CONTINUATION> ", PROMPT_COMMAND: "", ZDOTDIR: "", XDG_CONFIG_HOME: "" },
});
let screen = "";
let transcript = "";
const capture = terminal.onData((chunk) => { screen += chunk; transcript += chunk; });
const wait = (predicate: (output: string) => boolean, timeout = 5000) =>
waitForOsc7SessionOutput((_id, callback) => {
const listener = terminal.onData(callback);
return () => listener.dispose();
}, "real-pty", predicate, timeout);
try {
assert.ok(await wait((output) => output.endsWith(prompt)), "initial Bash prompt");
// --noediting keeps Bash on the kernel's canonical input path, unlike Readline.
const canonical = wait((output) => output.endsWith(prompt));
screen = "";
terminal.write("stty icanon; stty -a\r");
assert.match(await canonical || "", /(?:^|[;\s])icanon(?:[;\s]|$)/);
const writes: string[] = [];
const result = await runOsc7SetupAction({
status: "connected", sessionId: "real-pty", setupCommand: "exec-only-setup",
setupOsc7Tracking: async () => ({ success: false, code: 4 }),
isTerminalReadyForCommandInjection: () => screen.endsWith(prompt),
waitForSessionOutput: (_id, predicate, timeout) => wait(predicate, timeout),
writeToSession: (_id, command) => {
assert.ok(Buffer.byteLength(command) < 3000, "each typed line fits canonical input");
writes.push(command);
screen = "";
// Preserve the path for a shell-side cleanup assertion after the final command.
terminal.write(command.includes("base64 -d")
? ` __netcatty_test_file="$__netcatty_osc7_pty_file"; ${command}` : command);
},
});
assert.equal(result.success, true, `${result.error}\n${transcript.slice(-4000)}`);
assert.ok(writes.length > 3);
assert.ok(writes.reduce((total, command) => total + command.length, 0) > 4096);
assert.equal(markerCount(readFileSync(join(home, ".bashrc"), "utf8")), 2);
assert.ok(transcript.includes("\u001b]7;file://"));
assert.match(transcript, new RegExp(`\r?\n${OSC7_PTY_DONE_MARKER}bash\r?\n`));
assert.doesNotMatch(transcript, /CONTINUATION> /);
// A final shell assertion covers cleanup without exec sharing any staging file.
if (!screen.endsWith(prompt)) assert.ok(await wait((output) => output.endsWith(prompt)));
const cleaned = wait((output) => output.includes("\r\nCLEANED\r\n"));
terminal.write("test -n \"$__netcatty_test_file\" && test ! -e \"$__netcatty_test_file\" && test -z \"${__netcatty_osc7_pty_file-}\" && printf '\\n%s\\n' CLEANED\r");
assert.ok(await cleaned, "transport removes the PTY-local file and unsets its path");
} finally {
capture.dispose();
terminal.kill();
rmSync(home, { recursive: true, force: true });
}
});
test("PTY probe recognizes interactive bash and rejects a noninteractive shell", () => {
for (const interactive of [true, false]) {
const args = ["--noprofile", "--norc", ...(interactive ? ["-i"] : []), "-c",
buildOsc7PtyShellProbeCommand().replace(/\r$/, "\n")];
const output = execFileSync("/bin/bash", args, { stdio: "pipe" }).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_SHELL_MARKER}${interactive ? "bash" : "unknown"}\n`));
}
});
test("chunked PTY installer runs locally in bash, preserves hooks and stays idempotent with DONE ack", () => {
withTempHome("netcatty-osc7-inline-bash-", (home) => {
const rcPath = join(home, ".bashrc");
writeFileSync(rcPath, 'PROMPT_COMMAND="printf existing-title"\n');
const command = buildOsc7PtySetupCommands("bash").join("").replace(/\r/g, "\n");
for (let run = 0; run < 2; run += 1) {
const output = execFileSync("/bin/bash", ["--noprofile", "--norc", "-i", "-c", command], {
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", ZDOTDIR: "", XDG_CONFIG_HOME: "" },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_DONE_MARKER}bash\n`));
assert.doesNotMatch(output, /__NETCATTY_OSC7_PTY_FAILED__/);
assert.ok(output.includes("\u001b]7;file://"));
const rc = readFileSync(rcPath, "utf8");
assert.equal(markerCount(rc), 2);
assert.match(rc, /netcatty-osc7-version: 2/);
assert.match(rc, /printf existing-title/);
}
});
});
test("chunked PTY installer emits failure instead of DONE when install or reload fails", () => {
withTempHome("netcatty-osc7-inline-fail-", (home) => {
const blockedHome = join(home, "not-a-directory");
writeFileSync(blockedHome, "file");
writeFileSync(join(home, ".bashrc"), "return 1\n");
for (const targetHome of [blockedHome, home]) {
const output = execFileSync("/bin/bash", ["--noprofile", "--norc", "-c",
buildOsc7PtySetupCommands("bash").join("").replace(/\r/g, "\n")], {
env: { ...process.env, HOME: targetHome, ZDOTDIR: "", XDG_CONFIG_HOME: "" },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_FAILED_MARKER}bash\n`));
assert.doesNotMatch(output, /__NETCATTY_OSC7_PTY_DONE__/);
}
});
});
test("PTY probe and chunked installer remove their bash history entries", () => {
withTempHome("netcatty-osc7-inline-history-", (home) => {
const output = runInteractiveHistoryProbe({
shellPath: "/bin/bash",
shellArgs: ["--noprofile", "--norc", "-i"],
dumpHistoryCommand: `history > ${quoteShellArg(join(home, "history"))}`,
dumpPath: join(home, "history"),
env: { HOME: home, HISTFILE: join(home, ".bash_history"), SHELL: "/bin/bash" },
input: `echo keepme\n${buildOsc7PtyShellProbeCommand()}${buildOsc7PtySetupCommands("bash").join("")}`,
});
assert.match(output, /echo keepme/);
assert.doesNotMatch(output, /NETCATTY_OSC7|base64|history -d/);
});
});
test("PTY probe and chunked installer support zsh with shell-local ZDOTDIR", (t) => {
const shellPath = existingShells(["/bin/zsh", "/usr/bin/zsh"])[0];
if (!shellPath) return t.skip("zsh is not installed on this runner");
withTempHome("netcatty-osc7-inline-zsh-", (home) => {
const zdotdir = join(home, "custom-zsh");
const command = `ZDOTDIR=${quoteShellArg(zdotdir)}; ${buildOsc7PtyShellProbeCommand()}${buildOsc7PtySetupCommands("zsh").join("")}`;
const output = execFileSync(shellPath, ["-f", "-i", "-c", command.replace(/\r/g, "\n")], {
env: { ...process.env, HOME: home, SHELL: "/bin/unknown", ZDOTDIR: undefined },
stdio: "pipe",
}).toString("utf8");
assert.match(output, new RegExp(`\n${OSC7_PTY_SHELL_MARKER}zsh\n`));
assert.match(output, new RegExp(`\n${OSC7_PTY_DONE_MARKER}zsh\n`));
assert.equal(markerCount(readFileSync(join(zdotdir, ".zshrc"), "utf8")), 2);
assert.equal(existsSync(join(home, ".zshrc")), false);
});
});
test("buildOsc7TypedSetupCommand stays a single line for reliable history cleanup", async () => {
const sha256 = await getOsc7StagedScriptSha256();
for (const shell of ["bash", "zsh", "fish"] as const) {
+159 -1
View File
@@ -36,7 +36,7 @@ export type Osc7SetupRunResult = {
code?: number | null;
error?: string;
reloadCommand?: string;
/** Setup was retyped into the terminal for a user-switched (su/sudo) shell. */
/** Setup was sent through the foreground terminal. */
sentToTerminal?: boolean;
};
@@ -57,6 +57,14 @@ export type RunOsc7SetupActionOptions = {
},
) => void;
writeLocalTerminalData?: (data: string) => void;
/** Live prompt safety gate (drafts, sensitive prompts and alt-screen block). */
isTerminalReadyForCommandInjection?: () => boolean;
/** Subscribe synchronously (throw if unavailable); resolve null on timeout. */
waitForSessionOutput?: (
sessionId: string,
predicate: (output: string) => boolean,
timeoutMs: number,
) => Promise<string | null>;
};
export const shouldOfferOsc7SetupAction = ({
@@ -675,6 +683,89 @@ export const buildOsc7TypedSetupCommand = (
return ` ${run}; source (test -n "${DOLLAR}XDG_CONFIG_HOME"; and echo "${DOLLAR}XDG_CONFIG_HOME"; or echo "${DOLLAR}HOME/.config")/fish/config.fish >/dev/null 2>&1; __netcatty_osc7_cwd 2>/dev/null; true\r`;
};
export const OSC7_PTY_SHELL_MARKER = "__NETCATTY_OSC7_PTY_SHELL__=";
export const OSC7_PTY_ACK_MARKER = "__NETCATTY_OSC7_PTY_ACK__=";
export const OSC7_PTY_DONE_MARKER = "__NETCATTY_OSC7_PTY_DONE__=";
export const OSC7_PTY_FAILED_MARKER = "__NETCATTY_OSC7_PTY_FAILED__=";
type Osc7PtyShell = "bash" | "zsh";
// Split marker and value in printf so terminal input echo cannot acknowledge
// the probe/install. A leading newline separates markers from prompt/OSC data.
const ptyMarkerCommand = (marker: string, value: string): string =>
`printf '\\n%s%s\\n' '${marker}' ${value}`;
export const buildOsc7PtyShellProbeCommand = (): string =>
` case "$-" in *i*) if test -n "${DOLLAR}{ZSH_VERSION-}"; then ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "zsh")}; elif test -n "${DOLLAR}{BASH_VERSION-}"; then ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "bash")}; else ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "unknown")}; fi;; *) ${ptyMarkerCommand(OSC7_PTY_SHELL_MARKER, "unknown")};; esac; ${BASH_DELETE_MARKED_HISTORY_COMMAND}\r`;
/** PTY-local transport. Every line stays below Linux's canonical input limit. */
export const buildOsc7PtySetupCommands = (shell: Osc7PtyShell): string[] => {
const cleanup = shell === "bash" ? `; ${BASH_DELETE_MARKED_HISTORY_COMMAND}` : "";
const finish = (command: string): string => ` ${command}${cleanup}\r`;
const failed = ptyMarkerCommand(OSC7_PTY_FAILED_MARKER, shell);
const file = '"$__netcatty_osc7_pty_file"';
const commands = [finish(
`if __netcatty_osc7_pty_file=$(mktemp /tmp/.netcatty-osc7-pty.XXXXXX); then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, "0")}; else ${failed}; fi`,
)];
// Leave room for the append, ack, and Bash history cleanup on each line.
const payload = btoa(POSIX_SETUP_SCRIPT);
const chunkSize = 1800;
for (let offset = 0; offset < payload.length; offset += chunkSize) {
const ack = String(commands.length);
commands.push(finish(
`if test -n ${file} && printf '%s' '${payload.slice(offset, offset + chunkSize)}' >> ${file}; then ${ptyMarkerCommand(OSC7_PTY_ACK_MARKER, ack)}; else rm -f -- ${file}; unset __netcatty_osc7_pty_file; ${failed}; fi`,
));
}
const runner = 'c=$(base64 -d "$1") && printf "%s\\n" "$c" | sh';
const shellEnv = shell === "zsh" ? ' NETCATTY_ZDOTDIR="${ZDOTDIR:-}"' : "";
const run = `env NETCATTY_OSC7_FORCE_SHELL=${shell}${shellEnv} sh -c ${quoteForSingleQuotedShellString(runner)} sh ${file}`;
const rc = shell === "bash" ? '$HOME/.bashrc' : '${ZDOTDIR:-$HOME}/.zshrc';
commands.push(finish(
`if ${run} && . "${rc}" >/dev/null 2>&1 && osc7_cwd 2>/dev/null; then __netcatty_osc7_pty_result='${OSC7_PTY_DONE_MARKER}'; else __netcatty_osc7_pty_result='${OSC7_PTY_FAILED_MARKER}'; fi; rm -f -- ${file}; unset __netcatty_osc7_pty_file; printf '\\n%s%s\\n' "$__netcatty_osc7_pty_result" ${shell}; unset __netcatty_osc7_pty_result`,
));
return commands;
};
// Only accept complete output lines, including when a marker spans chunks.
const readPtyMarker = (output: string, marker: string): string | null =>
readMarkerLine(output.slice(0, output.lastIndexOf("\n") + 1), marker);
/** Short-lived observer; never consume/replay terminal backlog or flow acks. */
export const waitForOsc7SessionOutput = (
subscribe: (sessionId: string, onData: (chunk: string) => void) => () => void,
sessionId: string,
predicate: (output: string) => boolean,
timeoutMs: number,
): Promise<string | null> => {
let output = "";
let dispose: (() => void) | undefined;
let settled = false;
let result: string | null = null;
let resolveOutput: ((value: string | null) => void) | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = (value: string | null) => {
settled = true;
result = value;
clearTimeout(timer);
dispose?.();
resolveOutput?.(value);
};
// Let subscription errors throw before runOsc7SetupAction sends any input.
dispose = subscribe(sessionId, (chunk) => {
if (settled) return;
output = (output + chunk).slice(-65536);
if (predicate(output)) finish(output);
});
if (settled) {
dispose();
return Promise.resolve(result);
}
return new Promise((resolve) => {
resolveOutput = resolve;
timer = setTimeout(() => finish(null), timeoutMs);
});
};
const isOsc7SetupShell = (value: string): value is Osc7SetupShell =>
value === "bash" || value === "zsh" || value === "fish";
@@ -744,6 +835,8 @@ export const runOsc7SetupAction = async ({
setupOsc7Tracking,
writeToSession,
writeLocalTerminalData,
isTerminalReadyForCommandInjection,
waitForSessionOutput,
}: RunOsc7SetupActionOptions): Promise<Osc7SetupRunResult> => {
if (status !== "connected") {
return { success: false, error: "Terminal is not connected" };
@@ -788,6 +881,71 @@ export const runOsc7SetupAction = async ({
return { ...result, success: true, sentToTerminal: true };
}
// Only this confirmed action may use foreground setup on exec identity
// failures (#4052). Keep the OTHER_USER staged transport above unchanged.
const identityErrors = [
"Netcatty OSC 7 setup: could not identify the active terminal shell",
"Netcatty OSC 7 setup: active terminal shell did not match the current tab",
];
if (result.code === 4 || identityErrors.includes(result.stderr?.trim() || "")) {
const idleError = "Configure directory tracking needs an idle bash/zsh prompt with empty input. Clear pending input and try again, or configure OSC 7 manually.";
if (!waitForSessionOutput || !isTerminalReadyForCommandInjection || !isTerminalReadyForCommandInjection()) {
return { ...result, success: false, error: idleError };
}
// Output observers run before xterm has necessarily rendered the next prompt.
// Wait briefly for the live safety reader; never infer readiness from an ack.
const waitUntilPromptReady = async (): Promise<boolean> => {
const deadline = Date.now() + 5000;
while (!isTerminalReadyForCommandInjection()) {
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, 25));
}
return true;
};
const probeOutput = waitForSessionOutput(
sessionId,
(output) => readPtyMarker(output, OSC7_PTY_SHELL_MARKER) !== null,
5000,
);
const probe = buildOsc7PtyShellProbeCommand();
writeToSession(sessionId, probe, {
automated: true,
logRewrite: { sentCommand: probe, displayCommand: "" },
});
const shell = readPtyMarker(await probeOutput || "", OSC7_PTY_SHELL_MARKER);
if ((shell !== "bash" && shell !== "zsh") || !await waitUntilPromptReady()) {
return { ...result, success: false, error: idleError };
}
const completed = (output: string) => readPtyMarker(output, OSC7_PTY_DONE_MARKER) === shell;
const failed = (output: string) => readPtyMarker(output, OSC7_PTY_FAILED_MARKER) === shell;
const commands = buildOsc7PtySetupCommands(shell);
for (let index = 0; index < commands.length; index += 1) {
if (!await waitUntilPromptReady()) {
return { ...result, success: false, error: idleError };
}
const final = index === commands.length - 1;
const acknowledged = (output: string) => final
? completed(output) : readPtyMarker(output, OSC7_PTY_ACK_MARKER) === String(index);
// Subscribe before every write, and wait for this command's acknowledgement
// before typing another line (including when the shell uses canonical input).
const installOutput = waitForSessionOutput(sessionId, (output) => acknowledged(output) || failed(output), final ? 20000 : 5000);
const command = commands[index];
writeToSession(sessionId, command, {
automated: true,
logRewrite: { sentCommand: command, displayCommand: "" },
});
const output = await installOutput;
if (!output || failed(output) || !acknowledged(output)) {
return {
...result,
success: false,
error: "Foreground directory tracking setup failed or did not finish within 20 seconds. Check the terminal; manual OSC 7 configuration is also available.",
};
}
}
return { ...result, success: true, sentToTerminal: true, error: undefined };
}
if (!result.success || (typeof result.code === "number" && result.code !== 0)) {
return {
...result,
@@ -155,7 +155,7 @@ const formatPluginConnectionExitMessage = (event: TerminalSessionExitEvent): str
return `\r\n${lines.join("\r\n")}`;
};
const isAuthFailureMessage = (message: string): boolean => {
export const isAuthFailureMessage = (message: string): boolean => {
const normalized = message.toLowerCase();
return normalized.includes("all configured authentication methods failed") ||
normalized.includes("authentication failed") ||
@@ -3168,6 +3168,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
});
});
term.onBinary((data) => {
// Legacy mouse reports are wire bytes, not text input or broadcast keys.
const id = ctx.sessionRef.current;
if (id) ctx.terminalBackend.writeToSession(id, data, { binary: true });
});
const handleKittyKeyboardBroadcast = createKittyKeyboardBroadcastHandler({
resolveOptions: () => ({
kittyProtocolEnabled: kittyKeyboardProtocolEnabled,
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const start = source.indexOf(" term.onBinary((data) => {");
const end = source.indexOf(" const handleKittyKeyboardBroadcast =", start);
assert.ok(start >= 0 && end > start, "runtime must subscribe to xterm onBinary");
test("onBinary forwards unchanged bytes only to the current session without text handling", () => {
const writes: Array<{ id: string; data: string; binary: boolean }> = [];
const sessionRef: { current: string | null } = { current: "first" };
let receive!: (data: string) => void;
runInNewContext(source.slice(start, end), {
term: { onBinary(callback: typeof receive) { receive = callback; } },
ctx: {
sessionRef,
terminalBackend: {
writeToSession(id: string, data: string, options: { binary: boolean }) {
writes.push({ id, data, binary: options.binary });
},
},
},
handleTerminalInputData: () => assert.fail("binary reports must bypass text handling"),
});
const mouse = String.fromCharCode(0x1b, 0x5b, 0x4d, 0x20, 0x21, 0xc0);
receive(mouse);
sessionRef.current = "replacement";
const allBytes = String.fromCharCode(...Array.from({ length: 256 }, (_, i) => i));
receive(allBytes);
sessionRef.current = null;
receive(mouse);
assert.deepEqual(writes, [
{ id: "first", data: mouse, binary: true },
{ id: "replacement", data: allBytes, binary: true },
]);
});
@@ -20,7 +20,7 @@ function section(start: string, end: string): string {
}
const declarations = section(" let suppressNextTerminalDataBroadcast =", " const broadcastKittyInput =");
const keyboardCallback = section(" term.attachCustomKeyEventHandler((e: KeyboardEvent) => {", " const handleMiddleClick =");
const dataCallback = section(" term.onData((data) => {", " const handleKittyKeyboardBroadcast =");
const dataCallback = section(" term.onData((data) => {", " term.onBinary((data) => {");
const compositionMarker = section(" const markKittyCompositionPending =", " const finishKittyComposition =");
const textInputMarker = section(" const markKittyTextInput =", ' textarea?.addEventListener("compositionstart"');
const suppression = section(" const suppressTerminalBroadcast =", " // skipBroadcast");
@@ -1,5 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import xterm from "@xterm/xterm";
import {
MAX_TERMINAL_LINE_TIMESTAMP_ENTRIES,
@@ -18,6 +19,159 @@ import {
type StampCursorEstimate,
} from "./terminalLineTimestamps.ts";
import { MAX_INCOMPLETE_TERMINAL_CONTROL_SEQUENCE_CHARS } from "./terminalControlSequenceLimits.ts";
import { installEraseInDisplayHandlers } from "../clearTerminalViewport.ts";
const createRealTimestampTerm = (wipeScrollback = true) => {
const term = new xterm.Terminal({
cols: 40, rows: 6, scrollback: 20, allowProposedApi: true,
});
const handler = installEraseInDisplayHandlers(term, {
getClearWipesScrollback: () => wipeScrollback,
});
const writes: string[] = [];
const originalWrite = term.write.bind(term);
term.write = (data, callback) => {
assert.equal(typeof data, "string");
writes.push(data as string);
originalWrite(data, callback);
};
return {
term,
writes,
write: (data: string, second = 0) => new Promise<void>((resolve) => {
writeTerminalDataWithLineTimestamps(term, data, resolve, {
timestampDate: new Date(2026, 9, 29, 12, 0, second),
});
}),
dispose: () => { handler.dispose(); term.dispose(); },
};
};
test("real xterm keeps cleared prompts timestamped across chunk and second boundaries", async () => {
for (const wipe of [true, false]) {
for (const second of [0, 1]) {
for (const clear of ["\x1b[H\x1b[2J\x1b[3J", "\x1b[H\x1b[J", "\x1b[1;1f\x1b[2J"]) {
for (const chunking of ["merged", "separate", "bytewise"]) {
const fixture = createRealTimestampTerm(wipe);
const { term, write } = fixture;
try {
const initial = "history\r\n$ clear\r\n";
await write(initial);
const redraw = clear + "$ ";
const chunks = chunking === "merged" ? [redraw]
: chunking === "separate" ? [clear, "$ "] : [...redraw];
for (const chunk of chunks) await write(chunk, second);
assert.equal(term.buffer.active.cursorY, 0, "prompt is at the top");
assert.equal(term.buffer.active.getLine(term.buffer.active.baseY)?.translateToString(true), "$ ");
assert.deepEqual(getVisibleTerminalLineTimestampRows(term), [
{ row: 0, label: `12:00:0${second}` },
], JSON.stringify({ wipe, second, clear, chunking }));
assert.equal(fixture.writes.join(""), initial + redraw, "PTY bytes are unchanged");
} finally {
fixture.dispose();
}
}
}
}
}
});
test("real xterm timestamps repeated same-second clears and redraws without a newline", async () => {
const fixture = createRealTimestampTerm();
try {
await fixture.write("$ ");
for (let index = 0; index < 5; index += 1) {
// Ctrl+L-style redraw: no CR/LF to reset the timestamp segmenter.
await fixture.write("\x1b[H\x1b[2J$ ");
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:00" },
]);
assert.equal(getTerminalLineTimestampLedgerCount(fixture.term), 1);
}
} finally {
fixture.dispose();
}
});
test("real xterm preserves timestamped history when output and clear share a batch", async () => {
const fixture = createRealTimestampTerm(false);
try {
await fixture.write("old\r\n", 0);
await fixture.write("new\r\n\x1b[H\x1b[2J\x1b[3J$ ", 1);
fixture.term.scrollToTop();
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:00" },
{ row: 1, label: "12:00:01" },
{ row: 2, label: "12:00:01" },
]);
} finally {
fixture.dispose();
}
});
test("real xterm retains clear anchors after scrollback trim and terminal reflow", async () => {
const fixture = createRealTimestampTerm();
try {
await fixture.write("line\r\n".repeat(40));
await fixture.write("\x1b[H\x1b[2J\x1b[3J$ ", 1);
fixture.term.resize(20, 6);
await fixture.write("command\r\nnext", 2);
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:01" },
{ row: 1, label: "12:00:02" },
]);
} finally {
fixture.dispose();
}
});
test("real xterm does not treat OSC payloads or alternate-screen redraws as clear boundaries", async () => {
const fixture = createRealTimestampTerm();
try {
const data = "\x1b]0;title\x1b[H\x1b[2J\x07$ ";
await fixture.write(data);
assert.equal(fixture.writes.length, 1, "string controls retain the bulk path");
await fixture.write("\x1b[?1049h\x1b[H\x1b[2JTUI", 1);
assert.equal(fixture.writes.length, 2, "alternate-screen output retains the bulk path");
await fixture.write("\x1b[?1049l\r\n$ ", 2);
assert.deepEqual(getVisibleTerminalLineTimestampRows(fixture.term), [
{ row: 0, label: "12:00:00" },
{ row: 1, label: "12:00:02" },
]);
} finally {
fixture.dispose();
}
});
test("standalone cursor moves keep untouched suffix timestamps", async () => {
for (const position of ["\x1b[H", "\x1b[1;1f"]) {
const fixture = createRealTimestampTerm();
try {
await fixture.write("first\r\nsecond\r\n", 0);
await fixture.write(position + "new", 1);
assert.equal(fixture.term.buffer.active.getLine(1)?.translateToString(true), "second");
assert.equal(getVisibleTerminalLineTimestampRows(fixture.term).find(({ row }) => row === 1)?.label, "12:00:00");
assert.equal(fixture.writes.length, 2, "a standalone move does not split the write");
} finally {
fixture.dispose();
}
}
});
test("normal-screen cursor-heavy frames remain a single write", async () => {
const fixture = createRealTimestampTerm();
let notifications = 0;
const unsubscribe = onTerminalLineTimestampsChange(fixture.term, () => { notifications += 1; });
try {
const frame = Array.from({ length: 1000 }, (_, row) => `\x1b[${row % 6 + 1};1Hrow`).join("");
await fixture.write(frame);
assert.deepEqual(fixture.writes, [frame]);
assert.equal(notifications, 1, "one ledger notification per frame");
} finally {
unsubscribe();
fixture.dispose();
}
});
const createFakeTerm = (options: {
cols?: number;
@@ -323,17 +323,6 @@ const isBulkMeasurableEscapeSequence = (sequence: string): boolean =>
getAlternateScreenAction(sequence) === null
&& (getWraparoundAction(sequence) !== null || isSgrSequence(sequence));
const isPotentialAlternateScreenSequence = (sequence: string): boolean => {
if (!sequence.startsWith("\x1b[?")) return false;
const params = sequence.slice(3).split(";");
const alternateScreenModes = ["47", "1047", "1049"];
return params.some((part) => (
part === ""
|| alternateScreenModes.some((mode) => mode.startsWith(part) || part.startsWith(mode))
));
};
const isPrintableOutput = (char: string): boolean => {
if (char === "\t") return true;
const code = char.codePointAt(0);
@@ -1711,7 +1700,7 @@ const writeTerminalDataWithSecondLedger = (
const segments = store.segmenter.append(dataForTimestamps, stampDate);
const pendingEscapeSequence = store.segmenter.flushPendingEscapeSequence();
if (isPotentialAlternateScreenSequence(pendingEscapeSequence)) {
if (canRetainIncompleteTerminalControlSequence(pendingEscapeSequence)) {
store.timestampOnlyPrefix = pendingEscapeSequence;
}
@@ -1779,6 +1768,40 @@ const writeTerminalDataWithSecondLedger = (
});
};
/**
* Normal-screen erasure needs real parser coordinates. Keep erase writes
* separate from text so anchors are attached before an erase,
* and subsequent stamps are seeded after xterm has moved/trimmed the buffer.
* Standalone cursor moves stay on the bulk path: a backwards sparse stamp
* would overwrite the fill-forward time of untouched rows below it.
* Skip alternate-screen programs and string controls (OSC/DCS payloads).
*/
const findTimestampCursorBoundary = (term: XTerm, data: string): {
start: number;
end: number;
} | null => {
const prefix = getTimestampStore(term).timestampOnlyPrefix;
const input = prefix + data;
let altActive = term.buffer?.active?.type === "alternate";
for (let index = input.indexOf("\x1b"); index !== -1;) {
const parsed = readEscapeSequence(input, index);
if (!parsed?.complete) break;
const sequence = parsed.sequence;
const action = getAlternateScreenAction(sequence);
if (action) altActive = action === "enter";
const final = getCsiFinal(sequence);
const params = sequence.slice(2, -1);
if (!altActive && final === "J" && /^[\d;]*$/.test(params)) {
return {
start: Math.max(0, index - prefix.length),
end: parsed.endIndex + 1 - prefix.length,
};
}
index = input.indexOf("\x1b", parsed.endIndex + 1);
}
return null;
};
export const writeTerminalDataWithLineTimestamps = (
term: XTerm,
data: string,
@@ -1814,13 +1837,41 @@ export const writeTerminalDataWithLineTimestamps = (
return;
}
// Record/render separation: per-second ledger + sparse reflow anchors.
writeTerminalDataWithSecondLedger(
term,
data,
done,
options,
diagnostics,
shouldMeasureDiagnostics,
);
// Preserve arrival time across parser checkpoints, including a second rollover.
const writeOptions = { ...options, timestampDate: options?.timestampDate ?? new Date() };
const writeStamped = (part: string, onComplete: () => void) => {
writeTerminalDataWithSecondLedger(
term, part, onComplete, writeOptions, diagnostics, shouldMeasureDiagnostics,
);
};
const writeNext = (remaining: string): void => {
const boundary = findTimestampCursorBoundary(term, remaining);
if (!boundary) {
writeStamped(remaining, done);
return;
}
const writeControl = () => {
writeFallbackOnly(remaining.slice(boundary.start, boundary.end), () => {
const store = getTimestampStore(term);
store.segmenter.reset();
store.timestampOnlyPrefix = "";
// Erasure can dispose the current second's anchor. A redraw must be
// allowed to stamp again even when its arrival time is the same second.
store.lastStampSecondKey = null;
rebaseLedgerForScrollback(term, store);
notifyTimestampStore(store);
if (boundary.end < remaining.length) {
writeNext(remaining.slice(boundary.end));
} else {
done();
}
});
};
if (boundary.start > 0) {
writeStamped(remaining.slice(0, boundary.start), writeControl);
} else {
writeControl();
}
};
writeNext(data);
};
@@ -18,7 +18,6 @@ import {
reorderTerminalSidePanelTab,
fitTerminalSidePanelTabs,
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
TERMINAL_SIDE_PANEL_TAB_IDS,
type TerminalSidePanelTabId,
useTerminalSidePanelTabOrder,
} from '../../application/state/terminalSidePanelTabs';
@@ -821,7 +820,11 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
move: moveSidePanelTab,
resetLayout: resetSidePanelTabLayout,
partition: partitionSidePanelTabs,
} = useTerminalSidePanelTabOrder();
} = useTerminalSidePanelTabOrder(pluginSidebar.pages.map((page) => page.tool));
const availableSidePanelTabs = useMemo(() => [
...TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER, ...pluginSidebar.pages.map((page) => page.tool),
], [pluginSidebar.pages]);
const availableSidePanelTabSet = useMemo(() => new Set<SidePanelTab>(availableSidePanelTabs), [availableSidePanelTabs]);
const resolvedSidePanelTerminalTheme = useMemo(() => (
followAppTerminalTheme
? terminalTheme
@@ -1019,7 +1022,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const draggedTab = draggedSidePanelTabRef.current ?? transferredTab;
draggedSidePanelTabRef.current = null;
setDragOverSidePanelTab(null);
if (!TERMINAL_SIDE_PANEL_TAB_IDS.has(draggedTab)) return;
if (!availableSidePanelTabSet.has(draggedTab)) return;
const nextOrder = reorderTerminalSidePanelTab(
sidePanelTabOrder,
@@ -1030,7 +1033,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
if (nextOrder !== sidePanelTabOrder) {
setSidePanelTabOrder(nextOrder);
}
}, [dragOverSidePanelTab, setSidePanelTabOrder, sidePanelTabOrder]);
}, [availableSidePanelTabSet, dragOverSidePanelTab, setSidePanelTabOrder, sidePanelTabOrder]);
const handleSidePanelTabDragOver = useCallback((event: React.DragEvent, targetTab: TerminalSidePanelTabId) => {
if (!Array.from(event.dataTransfer.types).includes(SIDE_PANEL_TAB_DRAG_MIME)) return;
@@ -1163,7 +1166,12 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
}, [focusedPaneHost, handleSplitSidePanelPane]);
const { shown: configuredShownSidePanelTabs, collapsed: configuredCollapsedSidePanelTabs } = useMemo(() => {
const parts = partitionSidePanelTabs(TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER);
const partition = partitionSidePanelTabs(availableSidePanelTabs);
const parts = {
shown: partition.shown as SidePanelTab[],
collapsed: partition.collapsed as SidePanelTab[],
hidden: partition.hidden as SidePanelTab[],
};
// If an external path opens a hidden tab, still show its chip while active.
if (
activeSidePanelTab &&
@@ -1177,7 +1185,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
};
}
return parts;
}, [activeSidePanelTab, partitionSidePanelTabs]);
}, [activeSidePanelTab, availableSidePanelTabs, partitionSidePanelTabs]);
// Bottom dock spans the full layer width (below the workspace focus
// sidebar row), so measure tab overflow against the terminal layer itself.
const sidePanelTabFitWidth = isBottomDock ? terminalLayerWidth : shellWidth;
@@ -1198,7 +1206,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const sidePanelCustomizeItems = useMemo(
() =>
sidePanelTabOrder.map((tabId) => {
sidePanelTabOrder.filter((tabId) => availableSidePanelTabSet.has(tabId)).map((tabId) => {
const item = sidePanelTabItemById.get(tabId);
return {
id: tabId,
@@ -1206,7 +1214,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
icon: item?.icon,
};
}),
[sidePanelTabItemById, sidePanelTabOrder],
[availableSidePanelTabSet, sidePanelTabItemById, sidePanelTabOrder],
);
return (
@@ -1288,7 +1296,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const next = setSidePanelTabPlacement(
id,
placement,
TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER,
availableSidePanelTabs,
);
// Only close when hide actually stuck (not reverted by requireReachable).
if (activeSidePanelTab === id && (next.placement[id] ?? 'show') === 'hide') {
@@ -1296,7 +1304,7 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
}
}}
onMove={(id, direction) =>
moveSidePanelTab(id, direction, TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER)
moveSidePanelTab(id, direction, availableSidePanelTabs)
}
onReset={resetSidePanelTabLayout}
t={t}
@@ -1517,8 +1525,8 @@ function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelCon
const sessionId = pluginSidebar.sessionId!;
return <PersistentSidePanelPortal key={`${activeTabId}:${pane.id}:${sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
<PluginTerminalSidebarPageHost page={page} tabId={activeTabId} sessionId={sessionId}
focusOnReveal={activeSidePanelLayout?.focusedPaneId === pane.id}
adapter={pluginSidebar.contributions} getSession={ctx.getActiveTerminalSessionId}
showHeader={activePaneCount === 1}
getSession={ctx.getActiveTerminalSessionId}
unavailable={() => ctx.handleWithdrawPluginSidebarPane?.(activeTabId, pane.id, page.tool)}
focused={() => { if (activeTabStore.getActiveTabId() === activeTabId) ctx.handleFocusSidePanelPane?.(pane.id); }} />
</PersistentSidePanelPortal>;
@@ -0,0 +1,48 @@
# Scoped disconnected-session reconnect
## Objective and scope
Continue the workspace session continuity objective after merged PR #3885. Expose explicit reconnect for an existing, authorized, disconnected session without creating a tab, moving it, granting ownership, or replaying commands. Reuse the terminal's existing reconnect handler. Connected, authenticating, unmounted and already-reconnecting terminals must not be restarted by this operation.
## Plan
1. Add failing lifecycle, permission and renderer-registry tests.
2. Add a catalog/RPC/CLI operation using existing approval and scope enforcement.
3. Bound agent requests per session incident: three attempts, 2/5 second backoff and a 120 second starting window; only an observed connected state resets the incident. No timers queue delayed input or recovery.
4. Update bundled operator guidance, run focused regressions, lint and build, then submit a follow-up PR against current upstream.
## Status
- Planning: complete. Base: upstream main `0256e7471`; original checkout changes preserved.
- Tests: first seven new cases failed before implementation; 314 focused regressions now pass, including real MCP transport startup, root bridge scope/cancellation, permission dispatch, renderer lifecycle gates and existing terminal view regressions. CLI adapter suite: 16/16 pass after the additional required-session case. Targeted ESLint passes.
- Implementation: complete. Catalog/generated tools, CLI, progressive MCP `sessions` category, scoped main-process service and renderer reconnect handler are connected. Lost replies consume budget; explicit non-dispatch replies do not. Live connected snapshots and explicit tab closure clear incident state.
- Build: `npm run build` passed; final rebuild after the Vault queue bypass also passed (1m 46s, existing large-chunk warnings). Additional final renderer bridge suite: 92/92 passed; targeted lint passed again.
- Submission: [PR #3935](https://github.com/binaricat/Netcatty/pull/3935), branch `feat/scoped-session-reconnect-20261026`. Local validation complete; upstream CI/review pending.
## Findings and limits
- Expanded tests caught the missing progressive MCP category: the new tool initially prevented MCP startup. Added `sessions` to the loader and reran the real transport test successfully.
- Existing UI auto-reconnect remains independent; limits here apply to agent-dispatched attempts during the main-process lifetime. No second automatic retry loop or deferred reconnect queue is introduced.
- No real user SSH connection was disconnected for fault injection. End-to-end desktop sleep/wake and SSH network fault tests remain manual acceptance cases; automated checks exercise the existing runtime dispatch boundary without affecting user sessions.
- The production build does not replace the installed application; the new CLI command requires a running build containing this capability.
## Acceptance
- Original tab, workspace and session ID retained.
- Out-of-scope, cancelled, observer and revoked requests cannot reconnect.
- Concurrent requests do not create duplicate connections; no request waits for a future terminal mount.
- Authentication and active reconnect remain under the existing UI lifecycle.
- Reconnect acknowledgement never claims shell readiness or command completion.
- Retry exhaustion is retained across chat turns; no session/tab storm.
Restarting a connected/hung transport, moving sessions and remote process termination remain later increments. A reconnect does not prove an old process has stopped.
## Follow-up: bounded recovery orchestration
Goal: inspect the existing terminal, retry transient disconnects with the existing shared reconnect budget, and observe connection/prompt readiness without sending task commands. Extend the still-open PR #3935, based on unchanged upstream `0256e7471` (fetched again before this increment).
Plan: write lifecycle tests, implement a short bounded recovery operation, expose actual authentication/reconnecting state from the terminal registry, connect scope/cancel/readiness checks, then run regressions/lint/build and update the PR. Each call waits at most 20 seconds with short renderer deadlines; continuation calls share a 120-second incident window. Stop on authentication, missing handler, busy tracked jobs, cancellation, lost scope or retry exhaustion. Never open/close tabs or replay commands in this increment.
Status: implementation complete; 438 focused regressions and targeted ESLint pass. New missing-service, expiry, stopped-terminal and flapping-budget cases failed before their fixes. Final `npm run build` after budget hardening passed (1m 6s, existing chunk-size warnings). Existing reconnect CI passed before this extension, which needs fresh CI. Live network fault injection remains pending.
Findings: reusing only the low-level reconnect budget was insufficient when a flapping transport briefly connected and cleared that budget. The recovery operation now retains its own three-attempt count and 2/5 second backoff until a ready prompt is observed; calls still honor the shared single-reconnect budget. No timer survives a completed recovery call. Explicit user Disconnect/Cancel and authentication failure stop automatic recovery. Private reconnect dispatch carries a renderer expiry deadline to prevent late execution after timeout.
+9 -3
View File
@@ -6,7 +6,7 @@ Let an agent continue an authorized task across multiple sessions in its workspa
The workspace is the collaboration and permission boundary. The host grants access to particular sessions and management operations; the main process enforces those grants across RPC, IPC, and CLI paths. Being displayed in a workspace does not itself grant access, transfer ownership, or authorize closing another chat's session. Existing task authorization should be reusable for permitted recovery without repeated permission questions.
## Current delivery: PR 3885
## Merged foundation: PR 3885
- Open a new host session in an explicitly selected workspace and bind it to the requesting chat scope, or return an explicit binding failure.
- Preserve upstream reuse-first behavior and original ownership, including visibility of sessions already auto-reconnecting.
@@ -40,15 +40,21 @@ The Skill is operator guidance, not a backend retry controller or a grant of per
| --- | --- | --- |
| Open a new connection for parallel work | Requested workspace, usable chat scope, explicit reuse/create result, no duplicate retry tabs | Placement/scope delivered; existing reuse preserved |
| Slow local shell or SSH shell startup | No command bytes before readiness; bounded cancellation and explicit not-sent failure | Delivered for tracked shell PTYs |
| Brief network loss or sleep/wake disconnect | Reconnect original tab within its grant, preserve scope, resume after observed readiness | Planned explicit reconnect |
| Brief network loss or sleep/wake disconnect | Reconnect original tab within its grant, preserve scope, resume after observed readiness | Explicit reconnect implemented in the follow-up; live fault injection pending |
| Old terminal is unresponsive | Inspect/cancel appropriately, then use one permitted independent connection in the same workspace | Creation is available where exposed; automated recovery orchestration planned |
| A command may have executed before disconnect | Diagnose outcome; no automatic replay of a possibly mutating command | Skill guidance; richer delivery state planned |
| Permission revoked or user stops the task | No reconnect/reopen/resume that bypasses that decision | Existing enforcement retained; lifecycle regressions required for new tools |
| Same failure persists | Bounded retries/backoff, no tab storm, clear blocked-dependency report | Skill guidance; controller enforcement required with lifecycle tools |
## Follow-up: explicit reconnect (2026-10-26)
Implementation and validation are tracked in [scoped-session-reconnect-20261026.md](scoped-session-reconnect-20261026.md). The follow-up adds `session_reconnect` / CLI `session-reconnect` for existing scoped disconnected sessions, using the existing terminal lifecycle with no new tab or command replay. Main-process attempt limits and scope checks supplement the operator policy. Restart/move and independent recovery-tab orchestration remain later work.
The same open PR is being extended with `session_recover` / CLI `session-recover`: short bounded recovery calls inspect authentication, existing reconnects and tracked job activity, retry with the shared reconnect budget, and observe prompt readiness. Continuations retain the incident deadline. This orchestration does not create recovery tabs, move sessions, restart connected transports or replay task commands. Actual sleep/wake/network-fault acceptance remains outstanding.
## Status
Planning and Skill guidance for the continuity objective. PR 3885 remains the first implementation increment. No disconnected-session reconnect/restart/move tool is claimed by this document. Apply Skill updates through the application's normal distribution/synchronization path; do not rely on overwriting a user's separately maintained interruption skill.
PR 3885 is merged. The follow-up implements explicit scoped reconnect and bounded same-session recovery; restart/move remain planned. Apply Skill updates through the application's normal distribution/synchronization path; do not rely on overwriting a user's separately maintained interruption skill.
## Guidance validation
@@ -0,0 +1,64 @@
# 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
@@ -0,0 +1,163 @@
# 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.
@@ -0,0 +1,199 @@
# 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
@@ -0,0 +1,76 @@
# 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.
@@ -0,0 +1,42 @@
# 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.
+22 -42
View File
@@ -7,8 +7,12 @@ Provider subscription, terminal history reader or command execution API.
## Host-owned navigation
An Extension pages menu in the normal terminal toolbar lists visible pages
with their plugin source. Selecting one switches the existing focused pane;
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;
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
@@ -30,45 +34,21 @@ 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 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.
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.
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.
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.
## 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.
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.
+15 -52
View File
@@ -1,6 +1,6 @@
# Plugin UI contributions
Status: internal preview (`0.1.0-internal`), development gate only
Status: unpublished plugin platform; host-rendered browser views enabled in normal Settings
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,6 +31,10 @@ 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,
@@ -110,58 +114,17 @@ 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.
## Sandboxed custom views
## Host-rendered custom views
Each open custom view is a lazily created `WebContentsView` with:
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.
- `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.
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.
## Themes, localization, and accessibility
@@ -177,7 +140,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, tab, and settings placements use named regions.
semantics, while aside, panel, and tab placements use named regions.
## SDK surface
+15 -75
View File
@@ -1,80 +1,20 @@
# Vault extension pages
`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.
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.
## User workflow
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.
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.
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.
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.
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.
@@ -0,0 +1,79 @@
# Terminal Compose Context Chips
在终端撰写栏中增加 **Context Chips(上下文芯片)**,让用户在编写命令时直接查看和操作当前目录、Git 分支、变更统计、Node 版本及关联 PR。
## 开发目标
Context Chips 位于**快捷命令下方、命令输入区上方**,各芯片绑定当前聚焦的终端。首期实现目录切换,第二步增加 Git 功能,随后增加 Node 和 PR;详细 Git 变更面板暂不实现,但预留入口。
```text
撰写栏调高把手
快捷命令/固定片段 管理
Context Chips: [目录] [分支] [变更统计] [Node] [PR]
命令输入区 关闭
```
芯片显示上下文,点击就近操作。首期只显示已实现的目录芯片,其余按终端能力与上下文出现。共享终端主题、紧凑图标和文字、悬停说明及键盘焦点;不额外显示占据高度的行标题。窄分屏优先保留目录,长路径/分支名截断显示,完整值可查看。
| 功能 | 开发目标 | 调研与交互 |
| --- | --- | --- |
| Directory Picker | 显示当前位置;搜索子目录、返回父目录、选择后立即切换并关闭菜单 | [directory-picker.md](directory-picker.md) · [交互示意](directory-picker-demo.html) |
| Git Branch | 显示当前分支,搜索并切换其他分支 | [git-branch.md](git-branch.md) |
| Git Changes | 显示改动文件数及增删行;预留变更文件面板入口 | [git-changes.md](git-changes.md) |
| Node Version | 显示当前终端 Node 版本;支持的管理器可提供版本切换 | [node-version.md](node-version.md) |
| Pull Request | 显示当前仓库/分支关联 PR,提供查看入口 | [pull-request.md](pull-request.md) |
当前目录功能已有规格草案和模拟交互;其余四项仍有待确认行为,分别列在对应文档。HTML 使用虚构目录,不连接文件系统或真实终端。
## 架构与数据流
沿用现有 domain → application state → UI 分层,外部读取和执行经过 infrastructure/Electron bridge。新增 Context Chips 行负责呈现,各功能 hook 负责状态及异步编排;不把文件读取、Git 查询或命令执行塞进 `TerminalComposeBar`。
```mermaid
flowchart TD
UI[TerminalComposeBar / Context Chips] --> State[各功能状态 hook]
State --> Domain[路径 / 过滤 / 展示 / 操作资格]
State --> Cwd[terminalCwdStore 与现有 cwd 探测]
State --> IO[本地 / SSH / provider 适配]
IO --> Read[目录 / Git / Node / PR 读取]
State --> Action[绑定 session 的操作入口]
Action --> Ready[终端就绪与目标身份检查]
Ready --> Terminal[当前交互 shell]
Terminal --> Cwd
```
显示数据与操作分开:读取目录不等于切换目录,写入 cd 不等于切换成功。目录和 Node 切换必须影响目标交互 shell;独立 exec 子进程里的 cd 或版本切换不会改变用户终端。PR 联网失败不阻塞本地目录和 Git 上下文。
## 函数拆分与整合
| 现有位置 | 拟调整 | 影响范围 |
| --- | --- | --- |
| `components/terminal/TerminalComposeBar.tsx` | 增加独立 Context Chips 行与菜单,保留快捷命令和草稿输入职责 | 撰写栏 UI、焦点和草稿选区 |
| `components/terminalLayer/TerminalLayerWorkspaceSection.tsx` | 将聚焦会话与功能 view model 接入芯片行 | 切终端/分屏时的目标绑定 |
| `application/state/useComposeBarHeight.ts` | 将新增行计入高度预算,兼容保存的旧高度 | 撰写栏最小高度和可用输入区;建议从最小 104px 验证,默认是否调整另定 |
| `application/state/terminalCwdStore.ts`、`components/TerminalLayer.tsx`、`commandCwdProbe.ts` | 合并芯片可见时的 cwd 刷新需求与现有命令后探测 | cwd 展示、目录恢复与 SFTP 跟随共用同一更新链 |
| `components/SftpSidePanel.tsx` 的 `handleLocatePathInTerminal`、`domain/sftpLocatePathInTerminal.ts` | 抽取/复用“定位到指定终端”的受控执行边界 | SFTP 定位与目录芯片共用会话检查、cd 意图与执行规则 |
| `domain/sessionRestore.ts` 的 `resolveInteractiveTerminalCdIntent`、`terminalCommandInjectionReadyRegistry.ts` | 复用路径转义和空提示符判断,补充目标代次与结果确认 | 导航执行;不改成直接复用草稿发送入口 |
| `useSftpBackend.ts`、`useTerminalBackend.ts`、现有 local FS/SSH bridge | 复用目录读取与严格 cwd 查询,按需要增加轻量适配 | 本地/远程 I/O;不依赖挂载 SFTP 视图 |
| Git 上下文提供器(新增) | 分支与变更统计共享 repo/worktree 识别和刷新 | 后续 Git Branch、Git Changes 及 PR 的仓库身份 |
| Node/PR 提供器(新增) | runtime/管理器能力与远程 PR 查询分别管理 | 独立加载、缓存和失败状态 |
| `domain/sidePanelLayout.ts`、`useTerminalSidePanelLayoutState.ts` 及侧栏渲染映射 | 将来详细变更面板接入现有 per-terminal 布局 | 暂只预留打开面板动作;真正实现时再扩展工具类型和渲染器 |
建议新增目录状态 hook,将名称过滤、排序、home 缩写和父路径留在 domain;本地/远程读取和定向执行封装为适配。Context Chips 行接收明确的 view model 与动作,不提前建设通用芯片插件注册框架。
## 关键接入约束
- **cwd 刷新**:当前命令后探测受目录恢复/SFTP 跟随条件控制。新增芯片要合并刷新需求,不能假定 `terminalCwdStore` 始终最新,也不另开恒定轮询。严格查询不允许将 home 或新登录 shell 的目录冒充交互 shell cwd。
- **定向执行**:`handleComposeSend` 包含广播路由,不能作为芯片导航入口。复用 SFTP 定位中的敏感输入和空提示符检查,只操作菜单打开时绑定的 session;忙碌、TUI、密码或 shell 已有半截输入时不注入命令。
- **异步身份**:请求绑定 session、连接代次及当前路径;Git 再包含 repo/worktree。切会话、cwd 变化或重连后,旧读取和操作确认不得更新新目标。
- **结果确认**:操作已接受与实际成功分别处理。目录等待可信 cwd,分支/Node 等待各自结果;失败保留真实旧值,超时不自动重发。取消等待不能撤销已写入 PTY 的命令。
- **能力范围**:local/SSH/provider 与 shell 差异通过适配处理。现有本地 cwd 读取主要覆盖 macOS/Linux POSIX;Windows、受限 SSH、mosh/ET 和其他 provider 不能直接假定可用。
- **持久化与协议**:首期复用现有设置和 bridge,不需要新增业务存储 schema 或扩大 MCP/CLI 工具面。若新增 IPC,沿现有类型、preload 和 main-process 校验链扩展。
## 改动范围判断
首期是**跨 UI、应用状态、纯路径逻辑和现有终端适配的局部扩展**。主要整合点是 cwd 刷新需求与 SFTP 定位执行逻辑,不需要重构整个终端运行时。新增行影响高度和焦点;共用函数抽取会牵涉 SFTP 定位、cwd 恢复/跟随及广播隔离,需保留这些已有行为。
后续 Git 需要新增仓库状态读取与缓存;Node 需要当前 shell 环境和管理器适配;PR 需要托管服务身份及联网查询。详细 Git 变更面板涉及侧栏工具类型、布局与渲染,影响范围明显大于统计芯片,因此单独实施。各项不作为目录功能上线的前置条件。
源码分析基于上游提交 `0fe069e57546d0056937d138bfccccab69a26c69`;上述是拟议改动范围,尚未实现。
@@ -0,0 +1,114 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,">
<title>Netcatty Context Chips · Directory Chip 交互示意</title>
<style>
:root{color-scheme:dark;--bg:#111315;--surface:#1c1f22;--line:#34383c;--fg:#e0e3e6;--muted:#a0a6ac;--accent:#52b7d6}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
button,input,textarea{font:inherit}button{cursor:pointer;color:inherit}button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
.page{max-width:1160px;margin:auto;padding:42px 32px}h1{font-size:27px;line-height:1.3;margin:12px 0}p{margin:10px 0;color:var(--muted)}
.layout{display:grid;grid-template-columns:minmax(0,1fr) 230px;gap:24px;margin-top:26px}.terminal{min-width:0;border:1px solid var(--line);border-radius:8px;background:#15181b;overflow:hidden}
.tabbar{padding:10px 16px;border-bottom:1px solid var(--line);font-size:12px;color:var(--muted)}.tabbar span{color:var(--fg);margin-right:18px}
.output{height:380px;padding:22px 18px;font:12px/1.9 ui-monospace,SFMono-Regular,Consolas,monospace;white-space:pre-wrap;overflow:auto;color:#aab2b9}.output .prompt{color:#c1d7a2}.output .note{color:#6f7a83}
.compose{border-top:1px solid var(--line);padding:0 14px 14px;position:relative}.handle{height:6px;width:30px;border-bottom:2px solid #41464b;margin:0 auto 6px}.snippets{display:flex;gap:6px;height:30px;align-items:center;font:11px ui-monospace,monospace;color:var(--muted)}.snippet{padding:1px 7px;border-radius:3px;background:#25292d}
.context{position:relative;display:flex;gap:8px;align-items:center;height:30px;min-width:0}.chip{display:flex;align-items:center;gap:6px;max-width:100%;height:24px;padding:0 7px;border:1px solid #363b40;border-radius:4px;background:#101214;font:12px ui-monospace,monospace}.chip:hover,.chip[aria-expanded=true]{background:#272c31;border-color:#5b6872}.chip span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chip:disabled{cursor:wait;opacity:.7}.icon{width:14px;height:14px;flex-shrink:0;fill:none;stroke:currentColor;stroke-width:1.7}
.progress{font-size:11px;color:var(--muted)}textarea{display:block;width:100%;height:66px;resize:vertical;min-height:48px;border:0;background:transparent;color:var(--fg);font:12px/1.8 ui-monospace,monospace;margin-top:8px;padding:2px}
.popover{display:flex;flex-direction:column;position:absolute;z-index:2;left:0;bottom:calc(100% + 6px);width:min(360px,100%);max-height:310px;border:1px solid #383e44;border-radius:8px;overflow:hidden;background:var(--surface);box-shadow:0 10px 30px #0007}.popover[hidden]{display:none}.search{flex-shrink:0;padding:9px 12px;background:#121416;border-bottom:1px solid #2a2f34}.search input{width:100%;border:0;padding:0;background:transparent;color:var(--fg);font-size:13px;outline:none}.search input::placeholder{color:#858d94}
#directories{display:flex;flex-direction:column;min-height:0;overflow:hidden}#parent{flex-shrink:0}
.row{display:flex;align-items:center;gap:9px;padding:5px 12px;min-height:31px;cursor:pointer;color:#d7dcdf;font-size:13px}.row.active{background:var(--accent);color:#092631}.row:hover{background:#303940}.row.active:hover{background:var(--accent)}.row.parent{color:var(--muted);border-bottom:1px solid #343a40}.row.parent.active{color:#092631}.row .label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scroll{min-height:0;max-height:217px;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin}.empty{padding:20px 12px;color:var(--muted);font-size:12px}.foot{flex-shrink:0;padding:7px 12px;border-top:1px solid #343a40;display:flex;align-items:center;justify-content:space-between;font-size:11px;color:var(--muted)}.foot label{display:flex;align-items:center;gap:4px}.foot input{accent-color:var(--accent)}
aside{padding-top:2px}aside h2{font-size:14px;margin:0 0 12px}aside p{font-size:12px}.badge{font-size:11px;color:#b5c79b;border:1px solid #46503b;padding:2px 6px;display:inline-block;border-radius:3px}.checks{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--muted);margin-top:18px}.checks label{display:flex;align-items:center;gap:6px}.checks input{accent-color:var(--accent)}.status{min-height:44px;border-top:1px solid var(--line);padding-top:12px;margin-top:16px;font-size:12px;color:var(--muted)}.reset{border:1px solid #424a51;border-radius:4px;background:transparent;padding:4px 9px;font-size:12px;margin-top:12px}.warning{color:#ddbc85}kbd{font:11px ui-monospace,monospace;border:1px solid #3c4248;border-radius:3px;padding:0 4px}
@media(max-width:760px){.page{padding:24px 16px}.layout{grid-template-columns:minmax(0,1fr)}aside{border-top:1px solid var(--line);padding-top:18px}.output{height:355px}h1{font-size:23px}}
</style>
</head>
<body>
<main class="page">
<h1>目录即入口,选中即切换</h1>
<p>Context Chips 建议交互稿,位于快捷命令下方。首期仅演示目录芯片;使用虚构数据,不访问文件系统或执行命令。</p>
<div class="layout">
<section aria-label="终端交互示意" class="terminal">
<div class="tabbar"><span>本地终端</span>zsh · 模拟会话 A</div>
<div class="output" id="output"><span class="prompt">~/git/Netcatty</span>
$ pwd
/home/demo/git/Netcatty
<span class="note">从目录菜单选择一个文件夹。
每次选择都会关闭菜单;再次打开可继续导航。</span>
</div>
<div class="compose" id="compose">
<div class="handle" aria-hidden="true"></div>
<div class="snippets" aria-label="现有快捷片段区域"><span class="snippet">git status</span><span class="snippet">npm run dev</span></div>
<div class="context" role="group" aria-label="Context Chips">
<button id="trigger" class="chip" aria-haspopup="dialog" aria-expanded="true" aria-controls="picker"><svg class="icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M2 6V4h6l2 2h8v10H2zM2 8h16"/></svg><span id="path">~/git/Netcatty</span></button>
<span class="progress" id="progress"></span>
<div id="picker" class="popover" role="dialog" aria-label="更改工作目录">
<div class="search"><input id="query" type="search" placeholder="搜索当前目录下的文件夹…" aria-label="搜索子目录" aria-controls="directories" autocomplete="off" spellcheck="false"></div>
<div id="directories" role="listbox" aria-label="目录"><div id="parent"></div><div class="scroll" id="children"></div></div>
<div class="foot"><label><input id="hidden" type="checkbox">显示隐藏目录</label><span><kbd>↑</kbd> <kbd>↓</kbd> <kbd>Enter</kbd></span></div>
</div>
</div>
<textarea id="draft" aria-label="命令草稿" spellcheck="false">npm run test -- --watch</textarea>
</div>
</section>
<aside>
<span class="badge">首期建议范围</span>
<h2 style="margin-top:16px">可以直接操作</h2>
<p>搜索当前层子目录;按上下键后 Enter,或单击目录;父目录同样会切换并关闭。</p>
<p>命令草稿始终保留。约 650ms 后模拟 cwd 确认,chip 才更新。</p>
<h2 style="margin-top:22px">观察失败状态</h2>
<p>勾选下方“模拟忙碌”或“模拟失败”,核对无法注入时的反馈和目录真值保持。</p>
<p>目录在模拟 cwd 确认后更新;切换失败时保持原路径。</p>
<button class="reset" id="reset">重置演示</button>
</aside>
</div>
<div class="checks"><label><input type="checkbox" id="busy">模拟终端忙碌</label><label><input type="checkbox" id="fail">模拟 cd 失败</label></div>
<div id="status" class="status" role="status" aria-live="polite">示意:菜单已展开,可搜索或选择。所有路径均为虚构。</div>
</main>
<script>
const $=id=>document.getElementById(id);
const home='/home/demo';
const tree={
'/':['home','srv','tmp','var'], '/home':['demo'], '/home/demo':['.config','Downloads','git'],
'/home/demo/git':['Netcatty','sample-app','tools'],
'/home/demo/git/Netcatty':['.github','.vscode','application','build','components','dist','docs','domain','electron','examples','infrastructure','lib','node_modules','packages','public','scripts'],
'/home/demo/git/Netcatty/application':['i18n','state'], '/home/demo/git/Netcatty/components':['ai','sftp','terminal','terminalLayer','ui'],
'/home/demo/git/Netcatty/docs':['assets','features','plans','research'], '/home/demo/git/Netcatty/domain':[],
'/home/demo/git/sample-app':['src','tests'], '/home/demo/.config':['demo'], '/tmp':[], '/srv':['app'], '/var':['log']
};
let cwd=home+'/git/Netcatty', open=true, pending=false, active=0, rows=[], epoch=0, composing=false;
const folder='<svg class="icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M2 6V4h6l2 2h8v10H2zM2 8h16"/></svg>';
const up='<svg class="icon" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 16V4M5 9l5-5 5 5"/></svg>';
function display(p){return p===home?'~':p.startsWith(home+'/')?'~'+p.slice(home.length):p}
function parent(p){return p==='/'?null:p.slice(0,p.lastIndexOf('/'))||'/'}
function join(p,n){return (p==='/'?'':p)+'/'+n}
function status(s,error=false){$('status').textContent=s;$('status').classList.toggle('warning',error)}
function updateChip(){ $('path').textContent=display(cwd);$('trigger').title=cwd;$('trigger').setAttribute('aria-label','当前目录 '+display(cwd)+',更改工作目录');$('trigger').disabled=pending;$('progress').textContent=pending?'切换中…':'' }
function setOpen(v,focus=true){open=v;$('picker').hidden=!v;$('trigger').setAttribute('aria-expanded',String(v));if(v){$('query').value='';render();if(focus)$('query').focus()}else if(focus)$('trigger').focus()}
function paint(){rows.forEach((r,i)=>{r.node.classList.toggle('active',i===active);r.node.setAttribute('aria-selected',String(i===active))});if(rows[active])$('query').setAttribute('aria-activedescendant',rows[active].node.id);else $('query').removeAttribute('aria-activedescendant')}
function render(){
$('parent').replaceChildren();$('children').replaceChildren();rows=[];
const query=$('query').value.toLocaleLowerCase();const par=parent(cwd);
const names=(tree[cwd]||[]).filter(n=>($('hidden').checked||!n.startsWith('.'))&&n.toLocaleLowerCase().includes(query)).sort((a,b)=>a.localeCompare(b,'en',{numeric:true}));
function add(label,path,isParent){const node=document.createElement('div');node.className='row'+(isParent?' parent':'');node.role='option';node.id='dir-'+rows.length;node.title=path;node.innerHTML=isParent?up:folder;const text=document.createElement('span');text.className='label';text.textContent=label;node.append(text);const index=rows.length;rows.push({path,node});node.addEventListener('click',()=>select(index));node.addEventListener('pointermove',()=>{active=index;paint()});$(isParent?'parent':'children').append(node)}
if(par)add('..(父目录)',par,true);names.forEach(n=>add(n,join(cwd,n),false));
active=names.length?(par?1:0):-1;paint();
if(!names.length){const el=document.createElement('div');el.className='empty';el.textContent=query?'没有匹配的子目录':'没有子目录';$('children').append(el)}
}
function select(index){
if(!rows[index]||pending)return;
if($('busy').checked){status('请等待终端回到空命令提示符。未提交 cd,草稿保持。',true);return}
const target=rows[index].path, token=++epoch, fail=$('fail').checked;
setOpen(false,false);pending=true;updateChip();$('draft').focus();status('模拟已提交导航;等待目标会话 cwd 确认,暂不更改 chip。');
setTimeout(()=>{if(token!==epoch)return;pending=false;if(fail){status('模拟切换失败:无法进入目标目录。保留原实际目录,可重新打开菜单。',true)}else{cwd=target;const line=document.createElement('div');line.textContent='$ cd -- '+"'"+target+"'"+'\n'+display(cwd);$('output').append(line);$('output').scrollTop=$('output').scrollHeight;status('模拟 cwd 已确认:'+display(cwd)+'。菜单已关闭,命令草稿保留。')}updateChip()},650);
}
$('trigger').addEventListener('click',()=>setOpen(!open));$('query').addEventListener('input',render);$('hidden').addEventListener('change',render);
$('query').addEventListener('compositionstart',()=>composing=true);$('query').addEventListener('compositionend',()=>composing=false);
$('picker').addEventListener('keydown',e=>{if(e.key==='Escape'){e.preventDefault();e.stopPropagation();setOpen(false)}else if(e.key==='ArrowDown'||e.key==='ArrowUp'){e.preventDefault();e.stopPropagation();if(!rows.length)return;active=active<0?(e.key==='ArrowUp'?rows.length-1:0):Math.max(0,Math.min(rows.length-1,active+(e.key==='ArrowDown'?1:-1)));paint();rows[active].node.scrollIntoView({block:'nearest'})}else if(e.key==='Enter'&&e.target===$('query')){e.preventDefault();e.stopPropagation();if(!e.isComposing&&!composing)select(active)}});
document.addEventListener('pointerdown',e=>{if(open&&!$('picker').contains(e.target)&&!$('trigger').contains(e.target))setOpen(false,false)});
$('reset').addEventListener('click',()=>{epoch++;pending=false;cwd=home+'/git/Netcatty';$('busy').checked=false;$('fail').checked=false;$('hidden').checked=false;$('draft').value='npm run test -- --watch';updateChip();setOpen(true);status('演示已重置。虚构目录数据,不执行命令。')});
updateChip();render();$('query').focus();
</script>
</body>
</html>
@@ -0,0 +1,185 @@
# Directory Picker:目录显示与切换
日期:2026-10-29。状态:功能规格草案。总体目标与架构影响见 [README](README.md)。
参考行为:Warp 的目录芯片显示当前路径,点击打开搜索、父目录和直接子目录菜单;贡献者亲自操作确认,单击子目录会立即切换并关闭菜单。匹配算法、隐藏目录默认策略、父目录关闭行为与错误恢复仍未逐项验证;下文为 Netcatty 拟采用的明确规则。
本地模拟见 [directory-picker-demo.html](directory-picker-demo.html),不访问文件系统或真实终端。
## 1. 目标与首期边界
用户在撰写命令前,能看到**当前聚焦终端的真实工作目录**,并通过一个小菜单搜索当前层子目录、返回父目录、选择目录,使接下来执行的命令落在目标位置。
本期包含:目录芯片、当前 cwd 同步、弹出菜单、当前层搜索、父目录、直接子目录与目录符号链接、内部滚动、定向 cd、成功确认、键盘操作、错误恢复、草稿保护、会话隔离。
本期不包含:文件操作、文件预览、目录创建、全盘或递归搜索、历史目录、书签、路径自由输入、在菜单内下钻后单独确认、修改新会话默认目录、其他芯片功能。
范围建议:优先 macOS/Linux 本地 POSIX shell。SSH POSIX 是同一产品能力的目标场景,但是否同批上线应由就绪状态、cwd 可信度与连接能力共同决定;不得为了演示远程功能而在远程菜单中列举本地目录。Windows PowerShell/CMD、WSL、mosh/ET、跳板单通道、串口和网络设备的支持以能力探测为准,不能无条件套用 POSIX 命令。
## 2. 外观
目录芯片使用文件夹图标与路径,长路径保留尾部目录名,悬停显示完整路径。菜单锚定芯片上方、左对齐,间隔约 6–8px;宽约 360 CSS px,受窗口可用宽度约束。搜索行高约 34–36px,目录行约 30–32px,列表内部滚动。背景、文字及活动项沿用终端主题和应用 accent。
## 3. 信息模型与显示规则
必须分别保存三个概念:
| 值 | 含义 | 可否作为当前目录显示 |
| --- | --- | --- |
| confirmedCwd | 该会话最新可信 cwd 回报 | 可以 |
| listingBase | 本次菜单读取目录列表所依据的绝对路径 | 菜单上下文;不自动改变芯片 |
| pendingTarget | 用户已选择、尚未确认成功的路径 | 只能作为“切换中”提示 |
目录 chip:已知用户 home 时,可将 home 的完整路径前缀缩为 `~`;必须按目录边界比较,不能把 `/home/alice2` 错缩成 alice 的 home。远程 home 来自远程身份,不能使用应用本机 home。
显示绝对路径与命令参数是不同层次。`~/git` 只是显示,执行时应优先使用从目标会话/文件系统得到的绝对路径,不把 UI 中的波浪号文本当成真实文件名。
未知 cwd 显示“目录未知”,不显示 `/`、`~` 或某个 SFTP 面板曾浏览的位置。已有旧值但重连/有效性不明时显示弱化的“待确认”状态,不允许借旧值立即导航。
## 4. 打开、搜索与选择
### 4.1 打开
1. 单击目录芯片;再次单击关闭。
2. 菜单锚定芯片上方、左对齐;空间不足时调整位置,避免窗口边界和其他浮层裁切。
3. 固定本次 `sessionId + connectionGeneration + cwdRevision`。浮层打开后如果聚焦切到别的终端,关闭菜单,绝不把旧条目转发给新终端。
4. 搜索框自动获得焦点。显示当前层列表;若暂无缓存,显示“正在读取目录…”。
5. 列表读取不执行 cd,不影响草稿,不改变 SFTP 浏览位置。
### 4.2 搜索
首期定义为**当前目录直接子目录名称的过滤**,不递归、不访问其他目录、不调用系统全局搜索。这样行为和性能都清楚,不会让 SSH 中的一次打字变成递归扫描。
- 首期建议不区分大小写的连续子串匹配,支持中文及 Unicode 原名;不修改真正路径的大小写或 Unicode 字节表示。
- 输入查询即时过滤已读取列表,不因每个按键重新请求远程目录。
- 同一固定排序用于空查询和过滤结果;建议按名称的自然排序。不要依赖文件系统返回顺序。
- 父目录项不参与名称匹配,在非根目录置顶保留。无匹配时仍能返回父目录,结果区显示“没有匹配的子目录”。
- 第一个匹配的**子目录**为默认活动项,与参考菜单默认高亮子目录的展示一致;无子目录时默认不选中父目录,避免用户空列表按 Enter 意外离开当前目录。
- 查询只存在于当前打开周期,关闭后清空。下次从实际 cwd 重新打开。
- 占位文案建议“搜索当前目录下的文件夹…”,比全局“搜索目录”更准确地交代范围。
隐藏目录:Warp 的默认规则尚未验证。建议默认隐藏点目录/平台 hidden 条目,并提供可发现的“显示隐藏目录”开关;精简首期若不做开关,必须在帮助文案中说明且记录为已知限制。不要自行排除 `node_modules`、`build`、`dist` 等正常目录。
### 4.3 目录列表
父目录行:上箭头 + `..(父目录)`,固定在搜索框下,普通结果区内部滚动;根目录不显示可执行的父目录项。
目录行:文件夹图标 + 名称。只列真正目录和已确认目标是目录的符号链接;不列普通文件、链接到文件、断链和类型未知的链接。保留路径原始名称,展示截断时使用 tooltip,执行时不使用截断文本。
名称超长/包含控制字符时,显示层将不可见字符可视化,不能破坏列表。首期命令注入拒绝含 NUL、CR、LF、ESC 等终端控制字符的目标路径;提示“该目录名称暂不支持快捷切换”,用户仍可自行在 shell 中处理。不能因“已正确引号转义”就忽略终端输入控制字符。
列表最大高约 240–260 CSS px,外层菜单随内容收缩。滚轮只滚结果列表,不滚终端历史,也不把方向键发送给终端。超过约 500 条时建议虚拟化;具体阈值在实现中用性能结果确定,不把大列表截断成“没有更多目录”。
### 4.4 选择子目录或父目录
**统一动作:提交一次目录切换,并关闭菜单。** 子目录即时切换关闭来自用户对 Warp 的确认;父目录采用相同动作是 Netcatty 的一致性选择。
```text
打开菜单 → 搜索 / 移动活动项 → 单击或 Enter
→ 校验会话与 shell 就绪状态 → 定向提交 cd → 关闭菜单
→ 芯片显示切换中,保留旧 cwd → 实际 cwd 回报 → 更新芯片
```
- 前置校验失败:不发命令,菜单保留并显示原因。
- 已提交但结果失败/超时:保持旧的确认值,显示可重试提示。再次打开时从实际 cwd 重新读取。
- 写入被接受不代表成功;chip 不立即乐观变成目标路径。
- 同一时间每个会话最多一个切换操作,防止双击写入两条 cd。
- 用户选择的是 symlink 时,shell 可能回报逻辑路径或物理路径。确认规则需要识别等价路径,不因回报真实目标路径就误报失败;不要为了检测而额外改变 `cd -L/-P` 策略。
- 用户在操作期间手动运行别的 cd 时,以最新实际回报为准;已经被更新事件取代的旧操作不能再弹出“切换成功”。
## 5. 键盘、焦点与草稿
| 按键/操作 | 菜单行为 |
| --- | --- |
| Tab / Shift+Tab | 依次进入/离开芯片;浮层内部遵循既有 Popover 焦点规则 |
| Enter / Space(芯片聚焦) | 打开菜单 |
| 普通文字 | 输入搜索词,支持输入法组合 |
| Up / Down | 移动活动项,必要时滚入可视区;建议到边缘停止而不循环 |
| Enter(菜单内) | 激活当前条目;无活动项时无操作 |
| Escape | 关闭,回到打开前的焦点位置 |
| 单击外部 | 关闭,不执行目录切换,不强行夺走外部目标焦点 |
| 单击条目 | 选择、定向提交并关闭 |
输入法组合期间 Enter 只结束组合,不触发 cd。方向键不能穿透到命令历史。不要首期抢占 Alt+Up、Cmd+K 等现有终端快捷键;如后续增加全局入口,要经过 Netcatty 快捷键配置体系。
撰写栏草稿必须保持文本、选择范围与光标位置。目录动作不调用“发送草稿”,不清空草稿,也不把 cd 塞进撰写栏输入框。成功关闭菜单后回到撰写栏,用户可继续编辑原命令。
**区分两类草稿**:撰写栏 textarea 内的草稿允许保留;真实 shell 提示符里已有半截输入时,首期拒绝自动注入。不能清空后恢复终端命令行,也不能把 `cd` 拼接到用户已有文字后面。
无障碍:芯片是可聚焦 button,包含“当前目录…,更改工作目录”的可访问名称和 expanded 状态。浮层使用搜索输入 + 可键盘操作的 listbox;活动条目通过 aria-activedescendant 或等价模式表达。不要把“当前活动目录项”错误描述为已经生效的 cwd。结果数量、读取失败与切换完成可温和播报,避免每个按键打断读屏。
## 6. 终端操作规则
目录选择是用户显式操作,首期无需额外“确认 cd”弹窗,但以下状态不能执行:
- 会话未连接、重连中、已关闭,或菜单绑定的连接代次已改变。
- 密码、MFA、sudo 提示或其他敏感输入状态。
- vim、less、top、REPL、持续运行命令或 alternate screen。
- 未能可靠判断 shell 正在空提示符等待命令;包含 shell 端已有输入。
- 只读/观察会话或 provider 未提供受控执行能力。
- 支持的 shell/路径转义策略未知。
不自动 Ctrl+C,不发送 Ctrl+U,不尝试退出 TUI,不把导航排到任意正在运行的进程后面。可以在这些状态下允许只读浏览菜单,但条目激活前必须再次校验,并给出“请等待终端回到空命令提示符”等具体原因。
一次选择固定目标终端,不读取全局“当前终端”作为异步完成时的目标;无论广播开关如何,只发给该会话。不会修改 Electron 主进程工作目录,不会更改同一主机上的其他终端,也不会修改新会话启动目录设置。
POSIX 命令建议使用绝对路径与集中转义,如 `cd -- '/home/me/project dir'`。路径内单引号使用已有转义辅助逻辑;绝不 `eval`,不拼 `ls | grep`,不将查询词解释为 shell 代码。PowerShell/CMD 需要独立策略,不能套用此命令模板。
命令可正常回显到终端,保留用户操作可理解性;Netcatty 撰写栏发送历史不应混入这类内部导航动作。shell 自身历史是否记录 cd 随 shell 正常规则,不承诺隐身。
## 7. 状态与反馈
| 状态 | 芯片 | 菜单/操作 |
| --- | --- | --- |
| cwd 未知 | “目录未知”弱化 | 提供刷新;没有可信 cwd 时不从 home 猜测列举 |
| 已知且空闲 | 路径、文件夹图标 | 可浏览、可选择 |
| 目录读取中 | 路径不变 | 搜索栏保留,结果区 loading;旧目录列表不能假装为新结果 |
| 空目录 | 路径不变 | 父目录 +“没有子目录” |
| 无搜索结果 | 路径不变 | 父目录 +“没有匹配的子目录” |
| 无读取权限 | 路径不变 | “无法读取此目录”,可重试;若可信父路径已知可尝试父目录 |
| shell 忙碌/敏感 | 路径仍可显示 | 只读浏览或禁用并给出原因,禁止注入 |
| 切换已提交 | 旧路径 + 小型进度提示 | 菜单关闭,暂禁重复提交 |
| 切换确认 | 最新 cwd | 更新路径,恢复可操作 |
| 切换失败 | 保留实际 cwd | 简短失败提示,点击可重新打开菜单 |
| 超时 | 保留最后确认值并标记待确认 | 建议等待 prompt 后刷新;不自动重发 cd |
| 会话切换/关闭 | 跟随新会话或移除 | 关闭旧菜单、取消结果应用 |
建议产品目标:有缓存时打开反馈在约 100ms 内;无缓存立即显示读取状态。目录读取超时与 cwd 确认超时分别设计,SSH 应允许更长等待。可建议从本地 3s、SSH 8s 的确认预算开始验证,但不是服务质量承诺;超时不能作为执行失败的唯一证据,也不能触发无条件重试。
## 8. 验收场景
| ID | 场景 | 预期 |
| --- | --- | --- |
| DIR-01 | 在 `~/git` 打开 | 显示该终端实际路径;菜单上方锚定,搜索获焦 |
| DIR-02 | 输入 `net` | 只过滤当前层匹配目录;不递归,不每键联网 |
| DIR-03 | 点击 Netcatty | 一次 cd 发给绑定会话;菜单关闭;真实 cwd 回报后更新 |
| DIR-04 | 点击父目录 | 定向切到父目录并关闭;根目录无有效父目录项 |
| DIR-05 | 目录数超过可视高度 | 内部滚动可达最后一项,搜索框不滚走 |
| DIR-06 | 空目录/无匹配 | 各自正确文案;不把空结果当读取失败 |
| DIR-07 | 有撰写栏草稿和选区 | 浏览、取消、切换均保留;导航不发送草稿 |
| DIR-08 | shell 内已有半条命令 | 不清空、不拼接、不注入;给出就绪提示 |
| DIR-09 | 命令运行中、TUI、密码/MFA | 所有情况下均无导航字节写入 |
| DIR-10 | 广播开启、多个终端/分屏 | 只切换选定会话,其余 cwd 和输入不变 |
| DIR-11 | 菜单读取时切换终端/重连 | 旧结果作废,旧条目不能对新会话执行 |
| DIR-12 | 读取失败、无权限、目标被删除 | 明确错误、可重试;保留实际 cwd |
| DIR-13 | 空格、单引号、中文、`$()`、反引号的目录 | 正确当作路径;不产生额外 shell 行为 |
| DIR-14 | 目录名包含换行或控制字符 | 拒绝快捷切换且解释;不写终端控制字节 |
| DIR-15 | 目录 symlink/断链/文件链接 | 目录链接可用,确认允许等价路径;其他不冒充目录 |
| DIR-16 | Enter 输入法组合与上下键 | 不误导航;事件不落到终端/历史 |
| DIR-17 | 快速双击或重复 Enter | 同一切换仅提交一次 |
| DIR-18 | 目标写入已接受但 cd 实际失败 | 不乐观展示目标;cwd 保持真实值 |
| DIR-19 | 普通 cd、脚本 cd、OSC 7 外部更新 | 芯片随真实 cwd 更新,不依赖仅从菜单触发 |
| DIR-20 | 不同主题、200% 缩放、窄分屏 | 可读、可聚焦、浮层不溢出、不遮掉全部输入区 |
| DIR-21 | SFTP 在别的目录 | 列表仍以终端 cwd 为准;不强制改 SFTP 的手动位置 |
| DIR-22 | SSH 额外通道受限/provider 无能力 | 明确不支持,不隐式新建登录到其他目标 |
## 9. 待确定的功能范围
1. 本地 POSIX 与普通 SSH POSIX 是否同批交付;建议先验证既有就绪 reader 和严格 cwd 回报,再决定。
2. 隐藏目录开关是否首期包含;推荐包含以便进入 `.config` 等常见目录。
3. 对只读会话采用禁用整个入口还是允许只读列举;推荐允许浏览、禁用激活并展示原因。
4. 最小高度 104px 与默认 144px 是否适合现有撰写栏;使用真实 UI 截图核定。
核心交互保持“单击目录即时切换并关闭”。
参考:[Warp prompt](https://docs.warp.dev/terminal/appearance/prompt/);[2025 更新日志](https://docs.warp.dev/changelog/2025/)中的目录搜索记录。
@@ -0,0 +1,34 @@
# Git Branch:分支显示与切换
日期:2026-10-29。状态:初步调研,切换行为尚待验证。
## 功能目标
在当前终端位于 Git 仓库时显示分支图标和名称,点击弹出可搜索的分支列表,选择后切换该终端所对应仓库/worktree 的分支。非仓库时隐藏;detached HEAD 显示短提交哈希。长名称在芯片内截断,菜单提供完整名称。
## Warp 参考行为
已观察到分支搜索框、完整分支条目和整行高亮;芯片采用前部省略、保留尾部的展示。官方更新日志还包含 detached HEAD 展示短哈希及创建新分支入口。尚未亲自验证候选分支范围、选择后的关闭时机和切换结果。
## 拟议交互
- 点击芯片打开上方菜单,搜索框获焦;菜单样式与目录选择器一致。
- 当前分支有明确标记;上下键移动活动项,Enter 选择,Escape 关闭。
- 切换动作绑定当前 session 与 worktree,不参与终端广播,也不发送或清空命令草稿。
- 只有实际分支状态确认后才更新芯片;失败保留当前分支并显示原因。
- 遇到未提交改动冲突,不隐式 stash 或丢弃变更。创建分支、远程跟踪分支是否包含仍待确定。
## 待查清的行为
| 主题 | 问题 |
| --- | --- |
| 分支列表 | 本地/远程候选范围、排序、搜索规则、当前项标识 |
| 切换动作 | 执行方式、菜单关闭时机、结果确认及失败反馈 |
| 特殊状态 | 空仓库、unborn branch、detached HEAD、分支被删除 |
| worktree | `.git` 为文件、另一 worktree 占用目标分支、不同终端指向不同 worktree |
| 环境 | local/SSH、Git 缺失、权限或锁文件、忙碌终端时的能力限制 |
| 新建分支 | 入口、名称校验、起点与确认过程;是否纳入本期 |
仓库识别与刷新可和 [Git Changes](git-changes.md) 共用。切 cwd/会话或重连后,旧分支列表和切换结果必须失效。
参考:[Warp prompt](https://docs.warp.dev/terminal/appearance/prompt/);[2025 changelog](https://docs.warp.dev/changelog/2025/)的分支搜索;[2026 changelog](https://docs.warp.dev/changelog/2026/)的创建分支记录;[2023 changelog](https://docs.warp.dev/changelog/2023/)的 detached HEAD 展示。
@@ -0,0 +1,38 @@
# Git Changes:变更统计与面板入口
日期:2026-10-29。状态:初步调研;详细变更面板暂不实现。
## 功能目标
在当前终端位于 Git 仓库时显示改动文件数与增删行。清洁状态可显示 `± 0`;存在变化时展示如 `3 files · +24 -8` 的摘要。点击打开该仓库的变更文件面板是后续目标,本期只保留数据与动作边界。
## Warp 参考行为
本机观察到清洁状态的 `± 0`。官方说明变更芯片可显示文件数与增删行,点击打开 Code Review;面板默认位于右侧,可查看未提交变化,也支持与 main/master 或其他分支比较。尚未验证 staged、unstaged、未跟踪及二进制文件的实际计数。
## 拟议统计口径
- 初步建议展示**相对 HEAD 的未提交净变化**,与“当前分支相对 main 的差异”分开。
- 已暂存与未暂存变化不能简单相加,避免同一文件重复计数或抵消关系错误。
- 分别保留文件数、增加行、删除行;重命名、二进制与未跟踪状态需要明确表达。
- 二进制无法计行时不以 `0` 冒充无变化;没有统计能力或读取失败也不显示假 `± 0`。
- 统计查询按 repo/worktree 缓存,随 cwd、分支和文件变化刷新;详细 diff 在面板打开后单独加载。
以上是 Netcatty 的候选口径,需用受控文件改动核对后确定。
## 侧栏预留
统计模型至少区分比较基线、文件数、增删行、二进制和未跟踪状态。打开动作绑定 session、连接代次、repoRoot 和 worktreeRoot,例如 `openChangesPanel(target)`;真正实现时接入现有终端侧栏布局,避免不同终端共用错误仓库。
面板未实现时不展示可点击的虚假入口。文件列表、diff 编辑、stage、revert、discard、commit 和分支比较交互不纳入当前统计功能。
## 待查清的行为
| 主题 | 问题 |
| --- | --- |
| 计数 | HEAD/index/working tree 的关系,已暂存与未暂存抵消,空仓库 |
| 文件类型 | rename、二进制、删除、未跟踪及忽略文件的计数与显示 |
| 刷新 | 外部编辑、切分支、切目录、大仓库性能、local/SSH 差异 |
| 点击 | 清洁状态是否可点击、比较基线选择、面板与芯片的同步 |
参考:[Warp Code Review](https://docs.warp.dev/code/code-review/)。共享分支上下文见 [Git Branch](git-branch.md),侧栏影响范围见 [README](README.md)。
@@ -0,0 +1,35 @@
# Node Version:版本显示与切换
日期:2026-10-29。状态:初步调研,尚未形成实施规格。总体目标与架构影响见 [README](README.md)。
## 目标与范围
通过 **Node Version Chip** 显示当前终端实际使用的 Node 版本。版本管理器发现、可用版本列表、切换当前 shell 版本是额外能力,应分别确定范围,不能用 Electron 主进程的 Node 版本替代用户终端版本。
## 已知参考行为
Warp 已有 Node 版本芯片,官方 2025-09-10 更新日志记录点击切换版本。本机观察到芯片显示 `v26.13.0`,菜单却提示没有已安装版本,并提供 `nvm install node` 入口。菜单为空不能解释为“系统没有 Node”;当前 PATH 中可用的版本与管理器可管理的列表需要分开建模。
本机安装包的只读资源检查支持 nvm 管理的菜单线索,以及在启用芯片时按目录与 PATH 缓存版本检测的脚本路径。官方 2026-06-10 也记录了避免每次 prompt 启动 Node 的缓存修复。静态线索不等于已验证实际切换,也不能据此断言所有平台/管理器的行为。
## 待补充调研
| 主题 | 需确认 |
| --- | --- |
| 显示条件 | package.json、祖先目录、无 Git 项目、worktree、Node 缺失 |
| 版本列表 | 多版本排序、当前项、别名、没有可用版本的状态 |
| 切换流程 | 直接执行还是填入编辑器;关闭时机;目标 shell 的版本与 PATH 确认 |
| 管理器 | nvm 不存在、未加载、非 nvm 安装;首个支持范围 |
| 安装入口 | 命令按钮实际行为、确认过程与失败反馈 |
| 刷新 | cd、PATH 变化、外部版本切换、缓存失效与性能 |
| 环境 | bash/zsh/fish/PowerShell、local/SSH 的差异 |
## 功能范围待确定
至少验证一次“列表 → 选择 → 当前交互 shell 版本确认”,以及无管理器、空列表和失败状态。可先只做展示,再增加 nvm;fnm、Volta、asdf、mise 等均需独立调研。
共享提供器应区分 `activeVersion`、`managerKind`、`installedVersions` 和切换能力。
参考:[Warp prompt](https://docs.warp.dev/terminal/appearance/prompt/);[2026 changelog](https://docs.warp.dev/changelog/2026/)。
Node 版本切换另见 [2025 changelog](https://docs.warp.dev/changelog/2025/)。
@@ -0,0 +1,33 @@
# Pull Request:关联 PR 显示与查看
日期:2026-10-29。状态:初步调研,尚未形成实施规格。总体目标与架构影响见 [README](README.md)。
## 目标与范围
通过 **Pull Request Chip** 显示当前仓库/分支关联的 PR,并提供明确查看动作。创建、评论、提交、合并和 CI 操作不自动纳入。依赖 Git 上下文和远程查询,但联网失败不应阻塞目录导航或本地 Git 展示。
## 已知参考行为
本机 Warp 芯片行显示过 `PR #4056`。官方 2026-03-25 的更新日志记录:AI 输入模式中,当前分支有开放 GitHub PR 时可显示 PR 芯片。这个记录不能直接泛化为所有输入模式、平台或代码托管服务。
具体点击落点、认证来源、关联查询与多个候选的处理尚未验证。点击浏览器打开是可讨论的 Netcatty 方案,尚不能写成已确认的 Warp 行为。
## 待补充调研
| 主题 | 需确认 |
| --- | --- |
| 出现条件 | 无 PR、draft/open/closed/merged、状态过期 |
| 点击 | 浏览器、菜单或内嵌面板;hover 详情与 URL |
| 关联 | 多 remote、fork、同名分支、多个 PR、不同 base 与跨仓库 PR |
| 服务与身份 | GitHub/Enterprise/GitLab 的实际支持,私有仓库和认证边界 |
| 查询来源 | 参考产品可确认的机制与 Netcatty 可复用适配,不靠文案猜 API |
| 失败与刷新 | 离线、未登录、限流、切分支/目录、重连、缓存与旧结果失效 |
| 环境 | local/SSH 使用哪一侧身份,provider 不支持的反馈 |
## 功能范围待确定
先能明确说明“这个 PR 为什么属于当前终端的这个仓库和分支”,并验证点击与常见失败路径。未查询到、没有关联和查询失败应分开呈现。
慢请求和身份切换不能污染另一个终端;共享仓库/会话边界见 [README](README.md)。
参考:[Warp prompt](https://docs.warp.dev/terminal/appearance/prompt/);[2026 changelog](https://docs.warp.dev/changelog/2026/)。
+81
View File
@@ -0,0 +1,81 @@
"use strict";
/* global TextEncoder */
const { validatePluginInlineStyle } = require("./pluginViewStylePolicy.cjs");
// These are HTML primitives, not a fixed design-system component catalogue.
// Active elements, custom elements and URL attributes need separate authority.
const ELEMENTS = new Set(["form", "img", "div", "span", "p", "h1", "h2", "h3", "h4", "h5", "h6", "section", "article", "header", "footer", "main", "aside", "nav", "ul", "ol", "li", "dl", "dt", "dd", "strong", "em", "small", "b", "i", "code", "pre", "blockquote", "br", "hr", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "button", "input", "textarea", "label", "select", "option", "fieldset", "legend", "progress", "details", "summary"]);
const VOID_ELEMENTS = new Set(["br", "hr", "img", "input"]);
const ATTRIBUTES = new Set(["lang", "dir", "anchor", "src", "alt", "width", "height", "class", "id", "title", "role", "style", "name", "placeholder", "value", "checked", "disabled", "readonly", "required", "multiple", "selected", "type", "for", "tabindex", "min", "max", "step", "minlength", "maxlength", "rows", "cols", "colspan", "rowspan", "scope", "open", "autocomplete"]);
const INPUT_TYPES = new Set(["text", "search", "email", "url", "tel", "number", "password", "checkbox", "radio", "range", "date", "time", "datetime-local", "color", "button"]);
const NAME = /^[a-zA-Z0-9_-]{1,80}$/u;
const MAX_NODES = 1024;
const MAX_DEPTH = 24;
const MAX_VALUE = 8192;
const MAX_RECORDS = 2048;
function validateId(value, root = false) {
if (typeof value !== "string" || (root && value === "~" ? false : !NAME.test(value))) throw new TypeError("Invalid view node ID");
}
function record(value) {
return value !== null && typeof value === "object" && !Array.isArray(value)
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
}
function validateAttribute(element, name, value) {
if (typeof name !== "string" || !/^[a-z][a-z0-9-]{0,79}$/u.test(name) || (!ATTRIBUTES.has(name) && !/^aria-[a-z0-9-]+$/u.test(name) && !/^data-[a-z0-9-]+$/u.test(name))) throw new TypeError("Forbidden view attribute");
if (value === null) return;
if (typeof value !== "string" || value.length > MAX_VALUE || value.includes("\0")) throw new TypeError("Invalid view attribute value");
if (name === "type" && element === "input" && !INPUT_TYPES.has(value.toLowerCase())) throw new TypeError("Forbidden input type");
if (name === "type" && element === "button" && !["button", "reset", "submit"].includes(value.toLowerCase())) throw new TypeError("Forbidden button type");
if (name === "src" && (!/^asset:[a-zA-Z0-9_-][a-zA-Z0-9_./-]{0,255}$/.test(value) || value.slice(6).split("/").some((part) => !part || part === "." || part === ".."))) throw new TypeError("Only package images are permitted");
if (name === "style") validatePluginInlineStyle(value);
}
/** Validate a complete, data-only snapshot before touching a host DOM. */
function validatePluginRemoteTree(root) {
if (!record(root) || !Array.isArray(root.children) || Object.keys(root).some((key) => key !== "children")) throw new TypeError("Invalid view root");
const ids = new Set(); let count = 0; let textSize = 0;
const visit = (node, depth) => {
if (++count > MAX_NODES || depth > MAX_DEPTH || !record(node)) throw new TypeError("View tree exceeds limits");
validateId(node.id); if (ids.has(node.id)) throw new TypeError("Duplicate view node"); ids.add(node.id);
if (node.type === 3 || node.type === 8) {
if (Object.keys(node).some((key) => !["id", "type", "data"].includes(key)) || typeof node.data !== "string" || node.data.length > MAX_VALUE) throw new TypeError("Invalid view text");
textSize += node.data.length; if (textSize > 128 * 1024) throw new TypeError("View text exceeds limits"); return;
}
if (node.type !== 1 || typeof node.element !== "string" || !ELEMENTS.has(node.element)
|| !Array.isArray(node.children) || !record(node.attributes)
|| Object.keys(node).some((key) => !["id", "type", "element", "attributes", "children"].includes(key))) throw new TypeError("Forbidden view element");
if (VOID_ELEMENTS.has(node.element) && node.children.length) throw new TypeError("Void view elements cannot have children");
const attributes = Object.entries(node.attributes);
if (attributes.length > 64) throw new TypeError("Too many view attributes");
for (const [name, value] of attributes) {
validateAttribute(node.element, name, value);
textSize += name.length + (value?.length ?? 0);
if (textSize > 128 * 1024) throw new TypeError("View data exceeds limits");
}
for (const child of node.children) visit(child, depth + 1);
};
for (const child of root.children) visit(child, 1);
return true;
}
/** Preflight mutation size and shape before applying to an isolated data tree. */
function validatePluginMutationBatch(batch) {
if (!Array.isArray(batch) || batch.length > MAX_RECORDS) throw new TypeError("Invalid view mutations");
let size;
try { size = new TextEncoder().encode(JSON.stringify(batch)).byteLength; } catch { throw new TypeError("Invalid view mutations"); }
if (size > 256 * 1024) throw new TypeError("View mutations exceed limits");
for (const item of batch) {
if (!Array.isArray(item) || ![0, 1, 2, 3].includes(item[0])) throw new TypeError("Invalid view mutation");
validateId(item[1], true);
if (item[0] === 0) {
if (item.length !== 4 || !Number.isSafeInteger(item[3]) || item[3] < 0 || item[3] > MAX_NODES) throw new TypeError("Invalid insertion");
validatePluginRemoteTree({ children: [item[2]] });
} else if (item[0] === 1) {
if (item.length !== 3 || !Number.isSafeInteger(item[2]) || item[2] < 0 || item[2] > MAX_NODES) throw new TypeError("Invalid removal");
} else if (item[0] === 2) {
if (item.length !== 3 || typeof item[2] !== "string" || item[2].length > MAX_VALUE) throw new TypeError("Invalid text update");
} else if (item.length < 4 || item.length > 5 || (item[4] !== undefined && item[4] !== 2)) {
throw new TypeError("Only attribute updates are permitted");
} else validateAttribute("", item[2], item[3]);
}
return true;
}
module.exports = { validatePluginRemoteTree, validatePluginMutationBatch, validateAttribute, ELEMENTS };
+46
View File
@@ -0,0 +1,46 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { validatePluginRemoteTree, validatePluginMutationBatch } = require("./pluginRemoteTreePolicy.cjs");
const text = (id, data) => ({ id, type: 3, data });
const element = (id, name, attributes = {}, children = []) => ({ id, type: 1, element: name, attributes, children });
test("normal HTML layout and styles can be represented without executable code", () => {
const root = { children: [element("1", "section", { class: "card", style: "display:grid;grid-template-columns:1fr 2fr;gap:12px" }, [element("2", "h1", {}, [text("3", "Hello")]), element("4", "button", { type: "button" }, [text("5", "Click")])])] };
assert.equal(validatePluginRemoteTree(root), true);
assert.equal(validatePluginMutationBatch([[0, "~", root.children[0], 0], [3, "1", "class", "ready"], [2, "3", "Updated"]]), true);
});
test("untrusted content cannot introduce executable elements, URLs or dangerous properties", () => {
const attacks = [
element("1", "script"), element("1", "iframe"), element("1", "img", { src: "https://example.invalid" }),
element("1", "a", { href: "javascript:alert(1)" }), element("1", "div", { onclick: "alert(1)" }),
element("1", "input", { type: "file" }), element("1", "div", { style: "background:url(https://example.invalid)" }),
element("1", "div", { style: "color:red;} body{color:red" }),
element("1", "div", { __proto__: "bad" }, [text("1", "duplicate")]),
{ id: "1", type: 1, element: "div", attributes: {}, properties: { innerHTML: "bad" }, children: [] },
];
for (const attack of attacks) assert.throws(() => validatePluginRemoteTree({ children: [attack] }), undefined, JSON.stringify(attack));
assert.throws(() => validatePluginMutationBatch([[3, "1", "innerHTML", "<script>evil()</script>"]]));
assert.throws(() => validatePluginMutationBatch([[3, "1", "__proto__", {}]]));
assert.throws(() => validatePluginMutationBatch([[3, "1", "onclick", "evil()", 2]]));
assert.throws(() => validatePluginMutationBatch([[3, "1", "class", "x", 3]]));
});
test("remote trees and mutation batches are bounded", () => {
assert.throws(() => validatePluginRemoteTree({ children: Array.from({ length: 1025 }, (_, i) => text(`${i}`, "x")) }));
let node = text("25", "x"); for (let i = 24; i >= 0; i--) node = element(`${i}`, "div", {}, [node]);
assert.throws(() => validatePluginRemoteTree({ children: [node] }));
assert.throws(() => validatePluginMutationBatch(Array.from({ length: 2049 }, () => [2, "1", "x"])));
assert.throws(() => validatePluginMutationBatch([[2, "1", "x".repeat(8193)]]));
});
test("void children are rejected in snapshots and mutation insertions", () => {
for (const name of ["br", "hr", "img", "input"]) {
const node = element("void", name);
assert.equal(validatePluginRemoteTree({ children: [node] }), true);
node.children.push(text("child", "invalid"));
assert.throws(() => validatePluginRemoteTree({ children: [node] }), /Void view elements/);
assert.throws(() => validatePluginMutationBatch([[0, "~", node, 0]]), /Void view elements/);
}
});
+57
View File
@@ -0,0 +1,57 @@
"use strict";
// Plugin styles are untrusted: they are never inserted before this check.
const cssTree = require("css-tree");
const MAX_CSS_LENGTH = 64 * 1024;
const MAX_AST_NODES = 20_000;
const ALLOWED_AT_RULES = new Set(["media", "supports", "container", "keyframes", "-webkit-keyframes", "layer"]);
const ALLOWED_FUNCTIONS = new Set([
"var", "calc", "min", "max", "clamp", "abs", "sign", "round", "mod", "rem", "hypot", "pow", "sqrt", "log", "exp", "sin", "cos", "tan", "asin", "acos", "atan", "atan2",
"rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch", "color", "color-mix", "light-dark",
"linear-gradient", "radial-gradient", "conic-gradient", "repeating-linear-gradient", "repeating-radial-gradient", "repeating-conic-gradient",
"translate", "translatex", "translatey", "translatez", "translate3d", "scale", "scalex", "scaley", "scalez", "scale3d", "rotate", "rotatex", "rotatey", "rotatez", "rotate3d", "skew", "skewx", "skewy", "matrix", "matrix3d", "perspective",
"blur", "brightness", "contrast", "grayscale", "hue-rotate", "invert", "opacity", "saturate", "sepia", "drop-shadow",
"cubic-bezier", "steps", "linear", "minmax", "repeat", "fit-content", "env", "counter", "counters", "symbols",
]);
const DENIED_PROPERTIES = new Set(["behavior", "-moz-binding", "-webkit-app-region", "app-region"]);
const HOST_TOKENS = new Set(["--background", "--foreground", "--muted", "--muted-foreground", "--border", "--primary", "--primary-foreground"]);
const decode = (name) => cssTree.ident.decode(name).toLowerCase();
/** Returns canonical CSS only if every construct is safe for a clipped shadow root. */
function validatePluginCss(source, context = "stylesheet") {
if (typeof source !== "string" || source.length > MAX_CSS_LENGTH || source.includes("\0")) throw new TypeError("Invalid plugin stylesheet");
let ast;
try { ast = cssTree.parse(source, { context, parseCustomProperty: true, positions: false }); }
catch { throw new TypeError("Invalid plugin stylesheet"); }
let count = 0;
cssTree.walk(ast, (node) => {
if (++count > MAX_AST_NODES) throw new TypeError("Plugin stylesheet exceeds complexity limits");
if (node.type === "Raw" || node.type === "Url") throw new TypeError("Plugin stylesheet contains unsafe content");
if (node.type === "Atrule" && !ALLOWED_AT_RULES.has(decode(node.name))) throw new TypeError("Plugin stylesheet contains a forbidden rule");
if (node.type === "PseudoClassSelector" && ["host", "host-context", "global", "visited"].includes(decode(node.name))) throw new TypeError("Plugin selector crosses the view boundary");
if (node.type === "PseudoElementSelector" && ["part", "slotted"].includes(decode(node.name))) throw new TypeError("Plugin selector crosses the view boundary");
if (node.type === "Function") {
const name = decode(node.name);
if (!ALLOWED_FUNCTIONS.has(name)) throw new TypeError("Plugin stylesheet contains an unsafe function");
if (name === "var") {
const first = node.children?.first;
const variable = first?.type === "Identifier" ? cssTree.ident.decode(first.name) : "";
if (!variable.startsWith("--plugin-") && !HOST_TOKENS.has(variable)) throw new TypeError("Plugin stylesheet references an unapproved variable");
}
}
if (node.type === "Declaration") {
const property = decode(node.property);
if (DENIED_PROPERTIES.has(property) || (property.startsWith("--") && !property.startsWith("--plugin-"))) throw new TypeError("Plugin stylesheet contains a forbidden property");
if (property === "position") {
const first = node.value.children?.first;
if (!first || first.type !== "Identifier" || !["static", "relative", "absolute", "sticky"].includes(decode(first.name))
|| node.value.children.size !== 1) throw new TypeError("Plugin stylesheet contains unsafe positioning");
}
}
});
return cssTree.generate(ast);
}
function validatePluginStylesheet(source) { return validatePluginCss(source); }
function validatePluginInlineStyle(source) { return validatePluginCss(source, "declarationList"); }
module.exports = { validatePluginStylesheet, validatePluginInlineStyle, MAX_CSS_LENGTH, MAX_AST_NODES };
+26
View File
@@ -0,0 +1,26 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { validatePluginStylesheet } = require("./pluginViewStylePolicy.cjs");
test("plugin styling supports ordinary layouts, animation and scoped theme tokens", () => {
const css = `.layout { display: grid; grid-template-columns: 1fr 2fr; gap: 12px; --plugin-accent: #fa0; color: hsl(var(--foreground)); }
@media (max-width: 400px) { .layout { display:flex; flex-direction:column; } }
@keyframes spin { from { transform:rotate(0deg) } to { transform:rotate(360deg) } }
.action:hover { animation:spin 1s; color:var(--plugin-accent); }`;
assert.doesNotThrow(() => validatePluginStylesheet(css));
});
test("plugin CSS rejects network loads, escapes and access outside the surface", () => {
const attacks = [
'@import "https://bad.example/a.css";', '@\\69mport "https://bad.example/a.css";',
'.x{background:url(https://bad.example/x)}', '.x{--plugin-a:url(https://bad.example)}',
'.x{background:u\\72l(https://bad.example)}', '.x{background:i\\6d age-set("https://bad.example" 1x)}',
'@font-face{font-family:x;src:url(https://bad.example/font)}', ':host{position:absolute}',
':host-context(.secret) .x {color:red}', '.x::part(secret){color:red}',
'.x{position:fixed}', '.x{position:fi\\78 ed}', '.x{--plugin-pos:fixed;position:var(--plugin-pos)}', '.x{background:image("https://bad.example/x")}', '.x{-webkit-app-region:drag}', '.x{background:var(--host-secret)}',
'.x{--foreground:red}', '.x{content:attr(data-secret)}', '.x{color:red;}\0',
];
for (const attack of attacks) assert.throws(() => validatePluginStylesheet(attack), undefined, attack);
assert.throws(() => validatePluginStylesheet('x'.repeat(65537)));
});
+2
View File
@@ -71,6 +71,8 @@ module.exports = {
'domain/pluginRuntimePolicy.cjs',
'domain/pluginPermissionDescriptions.cjs',
'domain/pluginThemePack.cjs',
'domain/pluginRemoteTreePolicy.cjs',
'domain/pluginViewStylePolicy.cjs',
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
'domain/systemManager/containerRuntime.shared.cjs',
+37 -11
View File
@@ -395,12 +395,21 @@ function init(deps) {
transferBridge = deps.transferBridge;
}
/**
* Resolve Windows tar before switching to an upload-controlled directory.
*/
function resolveLocalTarCommand() {
return process.platform === 'win32'
? path.join(process.env.SystemRoot || process.env.windir || 'C:\\Windows', 'System32', 'tar.exe')
: 'tar';
}
/**
* Check if tar command is available on the system
*/
async function checkTarAvailable(signal) {
return new Promise((resolve) => {
const tar = spawn('tar', ['--version'], { stdio: 'ignore' });
const tar = spawn(resolveLocalTarCommand(), ['--version'], { stdio: 'ignore' });
let settled = false;
let childClosed = false;
let forceKillTimer = null;
@@ -484,24 +493,41 @@ async function compressFolder(folderPath, outputPath, compressionId, sendProgres
}
// Use tar with gzip compression, excluding macOS resource fork files
// -czf: create, gzip, file
// -C: change to directory (so we don't include the full path in archive)
// -czf -: create, gzip, write to stdout (the opened output file)
// cwd: run in the parent directory (so we don't include the full path in archive)
// --exclude='._*': exclude macOS resource fork files
// --exclude='.DS_Store': exclude macOS folder metadata files
// Windows tar.exe converts argv to the ANSI code page, so local paths are
// passed via cwd and a file descriptor instead of -C and -f. Explicitly
// exclude the archive when it lies inside the source tree.
const folderName = path.basename(folderPath);
const parentDir = path.dirname(folderPath);
const tar = spawn('tar', [
'-czf', outputPath,
'-C', parentDir,
let outputFd;
try {
outputFd = fs.openSync(outputPath, 'w');
} catch (err) {
reject(new Error(`Failed to create tar archive: ${err.message}`));
return;
}
const tarArgs = [
'-czf', '-',
'--exclude=._*',
'--exclude=.DS_Store',
'--exclude=.Spotlight-V100',
'--exclude=.Trashes',
folderName
], {
stdio: ['ignore', 'pipe', 'pipe']
'--exclude=.Trashes'
];
const relativeOutputPath = path.relative(folderPath, outputPath);
if (relativeOutputPath !== '..' && !relativeOutputPath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativeOutputPath)) {
tarArgs.push(`--exclude=${path.relative(parentDir, outputPath).split(path.sep).join('/')}`);
}
tarArgs.push(folderName);
const tar = spawn(resolveLocalTarCommand(), tarArgs, {
cwd: parentDir,
stdio: ['ignore', outputFd, 'pipe']
});
// tar holds its own handle; close ours so the archive can be removed later.
fs.closeSync(outputFd);
compression.process = tar;
const stderr = createBoundedUtf8Collector(MAX_LOCAL_TAR_STDERR_BYTES);
@@ -0,0 +1,131 @@
"use strict";
const assert = require("node:assert/strict");
const childProcess = require("node:child_process");
const fs = require("node:fs");
const Module = require("node:module");
const os = require("node:os");
const path = require("node:path");
const { EventEmitter } = require("node:events");
const test = require("node:test");
const localTarCommand = process.platform === "win32"
? path.join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "System32", "tar.exe")
: "tar";
// The bridge destructures spawn and getTempFilePath at load time, so record
// tar calls and redirect temp archives before requiring it.
let tempDir = null;
const spawnCalls = [];
const realSpawn = childProcess.spawn;
const originalLoad = Module._load;
childProcess.spawn = (command, args, options) => {
spawnCalls.push({ command, args, options });
return realSpawn(command, args, options);
};
Module._load = function load(request, parent, isMain) {
if (request === "./tempDirBridge.cjs") {
return { getTempFilePath: (fileName) => path.join(tempDir, `1_1_${fileName}`) };
}
return originalLoad.call(this, request, parent, isMain);
};
const compressUploadBridge = require("./compressUploadBridge.cjs");
childProcess.spawn = realSpawn;
Module._load = originalLoad;
function createFolder(t, parentName) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-compress-folder-"));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const folderPath = path.join(root, parentName, "folder");
fs.mkdirSync(folderPath, { recursive: true });
fs.writeFileSync(path.join(folderPath, "file.txt"), "payload");
return { root, folderPath };
}
async function compressAndList(folderPath) {
let archiveEntries = null;
const handlers = new Map();
compressUploadBridge._resetCompressionSupportCacheForTests();
compressUploadBridge.init({
sftpClients: new Map([["sftp-folder", {
client: {
writable: true,
exec(command, callback) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
stream.end = () => {};
stream.destroy = () => {};
callback(null, stream);
process.nextTick(() => {
if (command === "tar --version") stream.emit("data", Buffer.from("tar"));
stream.emit("close", 0);
});
},
},
__netcattyEndpointKey: "endpoint:folder",
}]]),
transferBridge: {
acquireTransferSessionLeases(_leaseId, payload) { return [payload.targetSftpId]; },
releaseTransferSessionLeases() {},
broadcastGlobalTransferEvent() {},
async startInternalTransfer(_event, payload) {
// Read the archive through stdin so its path never reaches tar argv.
archiveEntries = childProcess.execFileSync("tar", ["-tzf", "-"], {
input: fs.readFileSync(payload.sourcePath),
encoding: "utf8",
})
.split(/\r?\n/)
.filter(Boolean)
.map((entry) => entry.replace(/\/$/, ""))
.sort();
return { success: true };
},
async cancelTransfer() { return { success: true }; },
},
});
compressUploadBridge.registerHandlers({ handle(channel, handler) { handlers.set(channel, handler); } });
spawnCalls.length = 0;
const result = await handlers.get("netcatty:compress:start")({ sender: { id: 1, send() {} } }, {
compressionId: "compress-folder",
folderPath,
targetPath: "/srv",
sftpId: "sftp-folder",
folderName: "folder",
totalBytes: 7,
});
const tarCall = spawnCalls.find((call) => call.command === localTarCommand && call.args.includes("-czf"));
return { result, archiveEntries, tarCall };
}
test("local tar gets its paths through cwd and stdout instead of argv", async (t) => {
// Non-ASCII parent and temp directories, as with a Chinese Windows user name.
const { root, folderPath } = createFolder(t, "下载目录");
tempDir = path.dirname(folderPath);
const { result, archiveEntries, tarCall } = await compressAndList(folderPath);
assert.equal(result.success, true);
assert.equal(spawnCalls.find((call) => call.args.includes("--version"))?.command, localTarCommand);
assert.ok(tarCall, "local tar compression must be spawned");
assert.equal(tarCall.options.cwd, path.dirname(folderPath));
assert.equal(tarCall.args.includes("-C"), false, "parent directory must not be passed through argv");
assert.deepEqual(tarCall.args.slice(0, 2), ["-czf", "-"]);
assert.equal(typeof tarCall.options.stdio[1], "number", "the archive file must be tar's stdout");
assert.equal(tarCall.args.some((arg) => arg.includes(root)), false, "no local path may reach tar argv");
assert.equal(tarCall.args.at(-1), "folder");
assert.deepEqual(archiveEntries, ["folder", "folder/file.txt"]);
});
test("an archive written inside the source folder is excluded from itself", async (t) => {
const { folderPath } = createFolder(t, "parent");
tempDir = path.join(folderPath, "tmp");
fs.mkdirSync(tempDir);
const { result, archiveEntries, tarCall } = await compressAndList(folderPath);
assert.equal(result.success, true);
assert.ok(tarCall.args.includes("--exclude=folder/tmp/1_1_folder.tar.gz"));
assert.deepEqual(archiveEntries, ["folder", "folder/file.txt", "folder/tmp"]);
});
+71 -1
View File
@@ -14,7 +14,7 @@ const { PluginAgentMcpDelegations } = require("../plugins/agent/mcpDelegation.cj
const pluginAgentDelegations = new PluginAgentMcpDelegations();
const { existsSync } = require("node:fs");
const { toUnpackedAsarPath, getFreshIdlePrompt, formatSyntheticEcho } = require("./ai/shellUtils.cjs");
const { toUnpackedAsarPath, getFreshIdlePrompt, getVisibleIdlePrompt, formatSyntheticEcho } = require("./ai/shellUtils.cjs");
const { appendVaultAgentGuidance } = require("../shared/vaultAgentGuidance.cjs");
const {
checkBlocklistForShell,
@@ -804,6 +804,7 @@ function updateLiveSessionMetadata(sessionList) {
const next = new Map();
for (const entry of incoming) {
if (!entry || typeof entry !== "object" || !entry.sessionId) continue;
if (entry.connected === true) sessionService?.forgetReconnect(entry.sessionId);
next.set(entry.sessionId, {
hostname: entry.hostname || "",
label: entry.label || "",
@@ -1271,6 +1272,8 @@ function ensureOpenedSessionMetadata(chatSessionId, sessionId, result) {
*/
function forgetClosedTerminalSession(sessionId) {
if (!sessionId) return;
sessionService?.forgetReconnect(sessionId);
sessionService?.forgetRecovery(sessionId);
terminalSessionCloseRevision += 1;
closedTerminalSessionRevisions.delete(sessionId);
closedTerminalSessionRevisions.set(sessionId, terminalSessionCloseRevision);
@@ -1614,7 +1617,74 @@ function reportOpenedSessionActivity(event = {}) {
return sessionIdleManager.touch(null, sessionId);
}
// In the default terminal worker path the SSH runtime (and its prompt
// tracking state) lives in the worker, so the main-process `sessions` map is
// empty for it. Ask the owning worker for read-only prompt readiness. A miss
// or a worker failure yields null: an unknown state must never report ready.
// The probe is bounded well below the 20s recovery budget so a live-but-
// unresponsive worker cannot hang inspectRecovery past the recovery loop's
// deadline/cancel machinery.
const WORKER_SHELL_READINESS_TIMEOUT_MS = 2000;
async function queryWorkerShellReadiness(sessionId, status) {
if (status !== "connected" || !terminalWorkerManager?.request) return null;
// terminalWorkerManager.request has no cancellation support, so the probe
// cannot be aborted once posted. Race it against a timeout instead: a late
// worker response settles into an abandoned promise (fire-and-forget) and
// never mutates state after the timeout decision.
let timeoutId;
try {
const result = await Promise.race([
terminalWorkerManager.request("netcatty:ai:shellReadiness", { sessionId }, {}),
new Promise((resolve) => {
timeoutId = setTimeout(
() => resolve(null),
WORKER_SHELL_READINESS_TIMEOUT_MS,
);
}),
]);
if (result?.ok !== true || result.found !== true) return null;
return {
trackedShell: result.trackedShell === true,
ready: Boolean(result.freshIdlePrompt || result.visibleIdlePrompt),
};
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
}
sessionService = createSessionService({
validateReconnect: (params = {}) => {
const scopeErr = validateSessionScope(params.sessionId, params.chatSessionId, params.scopedSessionIds);
if (scopeErr) return { ok: false, error: scopeErr };
if (isChatSessionCancelled(params.chatSessionId)) return { ok: false, error: "Chat session is cancelled." };
if (permissionMode === "observer") return { ok: false, error: "Recovery is unavailable in observer mode." };
if (sessionIdleManager.isClosing(params.sessionId)) return { ok: false, error: "Session is closing." };
return { ok: true };
},
inspectRecovery: async (params, timeoutMs) => {
if (typeof invokeVaultAgentFn !== "function") return { ok: false, error: "Session recovery bridge is unavailable." };
sessionIdleManager.touch(null, params.sessionId);
const state = await invokeVaultAgentFn("session.inspectRecovery", { sessionId: params.sessionId }, { timeoutMs });
if (state?.ok !== true) return state;
const runtime = sessions?.get(params.sessionId);
const meta = getSessionMeta(params.sessionId, params.chatSessionId);
const probe = runtime ? {
trackedShell: typeof runtime._promptTrackTail === "string",
ready: Boolean(getFreshIdlePrompt(runtime) || getVisibleIdlePrompt(runtime)),
} : await queryWorkerShellReadiness(params.sessionId, state.status);
const trackedShell = Boolean(probe?.trackedShell) && !isNetworkDeviceLikeMeta(meta);
// shellReady semantics: transport not connected → false; connected but
// the shell is untracked or the probe missed/timed out → null (unknown);
// a tracked shell reports its observed readiness. It is advisory for
// callers: recovery never writes to the PTY, so an unknown or false
// readiness only asks for an inspect_shell step, never blocks.
const shellReady = state.status !== "connected" ? false : !probe ? null : trackedShell
? Boolean(probe.ready) : null;
return { ...state, shellReady, busy: Boolean(getSessionBusyError(params.sessionId)) };
},
invokeSessionAgent: (...args) => {
if (typeof invokeVaultAgentFn !== "function") {
return Promise.resolve({ ok: false, error: "Vault agent bridge is unavailable." });
Loaded 100 of 279 files, more files were not shown because too many files have changed in this diff. Show more