mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
733f44cfdc | ||
|
|
a8adeb86fe | ||
|
|
0c097a1260 | ||
|
|
5c9286775e | ||
|
|
618257e593 | ||
|
|
342941b618 | ||
|
|
1d369bf322 | ||
|
|
0d77a2e56c |
No files matched your search
@@ -1246,6 +1246,12 @@ Highlight the focused split pane:
|
||||
"settings.plugins.manager.remove": "Uninstall",
|
||||
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
|
||||
"settings.plugins.permissions.statusUnknown": "Status unknown",
|
||||
"plugins.sidebar.extensions": "Extension pages",
|
||||
"plugins.sidebar.selectTerminal": "Select an open terminal first.",
|
||||
"plugins.sidebar.loading": "Loading extension page…",
|
||||
"plugins.vault.loading": "Loading plugin page…",
|
||||
"plugins.vault.extensions": "Extensions",
|
||||
"plugins.vault.back": "Back to hosts",
|
||||
"plugins.interaction.title": "Plugin request",
|
||||
"plugins.interaction.sharing": "Text you share becomes available to this plugin and its granted capabilities, including network access. Reading does not approve a new network destination.",
|
||||
"plugins.interaction.input": "Input text",
|
||||
|
||||
@@ -1246,6 +1246,12 @@ Resalta el panel dividido enfocado:
|
||||
"settings.plugins.manager.remove": "Desinstalar",
|
||||
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
|
||||
"settings.plugins.permissions.statusUnknown": "Estado desconocido",
|
||||
"plugins.sidebar.extensions": "Páginas de extensiones",
|
||||
"plugins.sidebar.selectTerminal": "Selecciona primero un terminal abierto.",
|
||||
"plugins.sidebar.loading": "Cargando página de extensión…",
|
||||
"plugins.vault.loading": "Cargando página del plugin…",
|
||||
"plugins.vault.extensions": "Extensiones",
|
||||
"plugins.vault.back": "Volver a hosts",
|
||||
"plugins.interaction.title": "Solicitud de complemento",
|
||||
"plugins.interaction.sharing": "El texto compartido estará disponible para este complemento y sus capacidades autorizadas, incluida la red. Leer no autoriza un nuevo destino de red.",
|
||||
"plugins.interaction.input": "Introducir texto",
|
||||
|
||||
@@ -1141,6 +1141,12 @@ export const ruCoreMessages: Messages = {
|
||||
"settings.plugins.manager.remove": "Удалить",
|
||||
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
|
||||
"settings.plugins.permissions.statusUnknown": "Состояние неизвестно",
|
||||
"plugins.sidebar.extensions": "Страницы расширений",
|
||||
"plugins.sidebar.selectTerminal": "Сначала выберите открытый терминал.",
|
||||
"plugins.sidebar.loading": "Загрузка страницы расширения…",
|
||||
"plugins.vault.loading": "Загрузка страницы плагина…",
|
||||
"plugins.vault.extensions": "Расширения",
|
||||
"plugins.vault.back": "К хостам",
|
||||
"plugins.interaction.title": "Запрос плагина",
|
||||
"plugins.interaction.sharing": "Переданный текст доступен плагину и его разрешённым возможностям, включая сеть. Чтение не разрешает новый сетевой адрес.",
|
||||
"plugins.interaction.input": "Ввести текст",
|
||||
|
||||
@@ -1161,6 +1161,12 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
"settings.plugins.manager.remove": "卸载",
|
||||
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "状态未知",
|
||||
"plugins.sidebar.extensions": "扩展页面",
|
||||
"plugins.sidebar.selectTerminal": "请先选择已打开的终端。",
|
||||
"plugins.sidebar.loading": "正在加载扩展页面…",
|
||||
"plugins.vault.loading": "正在加载插件页面…",
|
||||
"plugins.vault.extensions": "扩展",
|
||||
"plugins.vault.back": "返回主机",
|
||||
"plugins.interaction.title": "插件请求",
|
||||
"plugins.interaction.sharing": "分享的文本将交给此插件及其已授权的能力(包括网络访问)。读取文本不代表批准新的网络目标。",
|
||||
"plugins.interaction.input": "输入文本",
|
||||
|
||||
@@ -1164,6 +1164,12 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
"settings.plugins.manager.remove": "卸載",
|
||||
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
|
||||
"settings.plugins.permissions.statusUnknown": "狀態不明",
|
||||
"plugins.sidebar.extensions": "擴充功能頁面",
|
||||
"plugins.sidebar.selectTerminal": "請先選擇已開啟的終端。",
|
||||
"plugins.sidebar.loading": "正在載入擴充功能頁面…",
|
||||
"plugins.vault.loading": "正在載入擴充功能頁面…",
|
||||
"plugins.vault.extensions": "擴充功能",
|
||||
"plugins.vault.back": "返回主機",
|
||||
"plugins.interaction.title": "外掛請求",
|
||||
"plugins.interaction.sharing": "分享的文字會交給此外掛及其已授權能力(包括網路存取)。讀取文字不代表核准新的網路目的地。",
|
||||
"plugins.interaction.input": "輸入文字",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const PLUGIN_VAULT_PAGE_PREFIX = 'plugin-vault:';
|
||||
|
||||
export interface PluginVaultPage {
|
||||
key: `plugin-vault:${string}`;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
version: string;
|
||||
viewId: string;
|
||||
title: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export function collectPluginVaultPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginVaultPage[] {
|
||||
return plugins.flatMap((plugin) => plugin.views
|
||||
.filter((view) => view.location === 'vault-page' && view.visible)
|
||||
.map((view): PluginVaultPage => ({
|
||||
key: `${PLUGIN_VAULT_PAGE_PREFIX}${view.id}`,
|
||||
pluginId: plugin.id, pluginName: plugin.displayName, version: plugin.version,
|
||||
viewId: view.id, title: view.title, icon: view.icon, order: view.order ?? 0,
|
||||
})))
|
||||
.sort((left, right) => left.order - right.order || left.viewId.localeCompare(right.viewId));
|
||||
}
|
||||
|
||||
export function resolvePluginVaultPage(pages: readonly PluginVaultPage[], section: string): PluginVaultPage | null {
|
||||
return pages.find((page) => page.key === section) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Native child views must yield to host dialogs and overlapping portals. */
|
||||
export function isPluginViewOccluded(mount: HTMLElement): boolean {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
if (!mount.isConnected || mount.closest('[hidden], [inert], [aria-hidden="true"]')) return true;
|
||||
const overlays = mount.ownerDocument.querySelectorAll(
|
||||
'[data-app-lock-overlay], [role="dialog"], [role="alertdialog"], '
|
||||
+ '[role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
);
|
||||
return [...overlays].some((overlay) => {
|
||||
if (mount.contains(overlay)) return false;
|
||||
const style = getComputedStyle(overlay);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || overlay.closest('[hidden], [inert]')) return false;
|
||||
if (overlay.matches('[data-app-lock-overlay], [role="dialog"][aria-modal="true"], [role="alertdialog"]')) return true;
|
||||
const other = overlay.getBoundingClientRect();
|
||||
return other.width > 0 && other.height > 0 && other.x < rect.right && other.right > rect.x
|
||||
&& other.y < rect.bottom && other.bottom > rect.y;
|
||||
});
|
||||
}
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { useToolbarItemLayout } from './useToolbarItemLayout';
|
||||
import type { SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
import type { BuiltinSidePanelTool, SidePanelTool } from '../../domain/sidePanelLayout';
|
||||
|
||||
export type TerminalSidePanelTabId = SidePanelTool;
|
||||
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
|
||||
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: BuiltinSidePanelTool[] = [
|
||||
'sftp',
|
||||
'browser',
|
||||
'scripts',
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
|
||||
import { resolvePluginViewWindowScope } from './pluginViewScopes';
|
||||
import { isPluginViewOccluded } from './pluginViewOcclusion';
|
||||
import type { UsePluginContributionsResult } from './usePluginContributions';
|
||||
|
||||
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
|
||||
|
||||
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
|
||||
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
|
||||
viewId: string;
|
||||
version: string;
|
||||
slot: string;
|
||||
active: boolean;
|
||||
context: Record<string, unknown>;
|
||||
adapter: ViewAdapter;
|
||||
subscribeHidden(listener: () => void): () => void;
|
||||
unavailable(): void;
|
||||
focused?(): void;
|
||||
focusOnReveal?: boolean;
|
||||
}) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const adapterRef = useRef(adapter);
|
||||
const unavailableRef = useRef(unavailable);
|
||||
const focusedRef = useRef(focused);
|
||||
const revealFocusRef = useRef(focusOnReveal);
|
||||
const requestFocusRef = useRef<() => void>(() => {});
|
||||
revealFocusRef.current = focusOnReveal;
|
||||
focusedRef.current = focused;
|
||||
adapterRef.current = adapter;
|
||||
unavailableRef.current = unavailable;
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!active || !mount) return;
|
||||
let cancelled = false;
|
||||
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
|
||||
const instanceId = crypto.randomUUID();
|
||||
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
|
||||
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
|
||||
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
|
||||
const close = () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
lifecycle.markViewClosed(viewId);
|
||||
void closeView(instanceId).catch(() => {});
|
||||
};
|
||||
const failCurrent = () => {
|
||||
if (cancelled) return;
|
||||
close();
|
||||
unavailableRef.current();
|
||||
};
|
||||
const unsubscribe = onViewClosed((event) => {
|
||||
lifecycle.handleHostClose(event.instanceId);
|
||||
if (event.instanceId === instanceId && !cancelled) {
|
||||
close();
|
||||
unavailableRef.current();
|
||||
}
|
||||
});
|
||||
const stopOnHide = subscribeHidden(close);
|
||||
const stopOnFocus = onViewFocused((event) => {
|
||||
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
|
||||
});
|
||||
const bounds = () => {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
|
||||
};
|
||||
let opened = false;
|
||||
let lastBounds = '';
|
||||
let visible = false;
|
||||
const focus = () => {
|
||||
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
|
||||
};
|
||||
requestFocusRef.current = focus;
|
||||
const updateVisibility = () => {
|
||||
if (!opened || cancelled) return;
|
||||
const rect = mount.getBoundingClientRect();
|
||||
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
|
||||
if (visible === next) return;
|
||||
visible = next;
|
||||
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
|
||||
};
|
||||
const resize = () => {
|
||||
if (opened && !cancelled) {
|
||||
const next = bounds();
|
||||
const key = JSON.stringify(next);
|
||||
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
|
||||
}
|
||||
updateVisibility();
|
||||
};
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
|
||||
observer?.observe(mount);
|
||||
const mutations = new MutationObserver(resize);
|
||||
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
|
||||
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('scroll', resize, true);
|
||||
setLoading(true);
|
||||
const opening = lifecycle.beginOpen({ viewKey: viewId });
|
||||
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
|
||||
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
|
||||
await closeView(result.instanceId);
|
||||
return;
|
||||
}
|
||||
opened = true;
|
||||
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
|
||||
resize();
|
||||
setLoading(false);
|
||||
}).catch(() => {
|
||||
if (!cancelled) { close(); unavailableRef.current(); }
|
||||
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
|
||||
return () => {
|
||||
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
|
||||
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
|
||||
};
|
||||
}, [active, context, slot, subscribeHidden, version, viewId]);
|
||||
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
|
||||
return { mountRef, loading };
|
||||
}
|
||||
@@ -88,10 +88,12 @@ export interface UsePluginContributionsResult {
|
||||
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(
|
||||
@@ -196,6 +198,9 @@ export function usePluginContributions(
|
||||
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,
|
||||
@@ -218,6 +223,9 @@ 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,
|
||||
@@ -239,9 +247,11 @@ export function usePluginContributions(
|
||||
selectSettingPath,
|
||||
openView,
|
||||
closeView,
|
||||
focusView,
|
||||
setViewBounds,
|
||||
setViewVisibility,
|
||||
setEnvironment,
|
||||
onViewClosed,
|
||||
onViewFocused,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useMemo, useRef, useSyncExternalStore } from 'react';
|
||||
import { useI18n } from '../i18n/I18nProvider';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { collectSidePanelPanes, isPluginSidePanelTool, type SidePanelLayout } from '../../domain/sidePanelLayout';
|
||||
import { activeTabStore, useActiveTabId } from './activeTabStore';
|
||||
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot } from './sidePanelLiveStore';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
|
||||
|
||||
export const OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT = 'netcatty:open-plugin-terminal-sidebar';
|
||||
export const PLUGIN_TERMINAL_SIDEBAR_CONTEXT = Object.freeze({ 'netcatty.surface': 'terminal-sidebar' });
|
||||
/** Required production view-context projection, not a plugin-facing API. */
|
||||
export interface PluginSidebarHostActions {
|
||||
getActiveTerminalSessionId(): string | null;
|
||||
handleSwitchSidePanelTab(tool: import('../../domain/sidePanelLayout').SidePanelTool): void;
|
||||
handleWithdrawPluginSidebarPane(tabId: string, paneId: string, expectedTool: import('../../domain/sidePanelLayout').SidePanelTool): void;
|
||||
}
|
||||
export interface PluginTerminalSidebarPage {
|
||||
tool: `plugin-view:${string}`;
|
||||
viewId: string;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
version: string;
|
||||
title: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
order: number;
|
||||
}
|
||||
export function collectPluginTerminalSidebarPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginTerminalSidebarPage[] {
|
||||
return plugins.flatMap((plugin) => plugin.views.filter((view) => view.location === 'terminal-sidebar' && view.visible)
|
||||
.map((view): PluginTerminalSidebarPage => ({ tool: `plugin-view:${view.id}`, viewId: view.id, pluginId: plugin.id,
|
||||
pluginName: plugin.displayName, version: plugin.version, title: view.title, icon: view.icon, order: view.order ?? 0 })))
|
||||
.sort((a, b) => a.order - b.order || a.viewId.localeCompare(b.viewId));
|
||||
}
|
||||
export function requestOpenPluginTerminalSidebar(viewId: string, sessionId: string) {
|
||||
window.dispatchEvent(new CustomEvent(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, { detail: { viewId, sessionId } }));
|
||||
}
|
||||
export function usePluginTerminalSidebar(host: {
|
||||
layouts: ReadonlyMap<string, SidePanelLayout>;
|
||||
getSession(): string | null;
|
||||
isTerminalTab(tabId: string): boolean;
|
||||
select(tool: `plugin-view:${string}`): void;
|
||||
withdraw(tabId: string, paneId: string, tool: `plugin-view:${string}`): void;
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
const activeTabId = useActiveTabId();
|
||||
// Follow workspace focus without subscribing the whole sidebar to cwd/title
|
||||
// or forwarding the host's live session DTO to a plugin.
|
||||
useSyncExternalStore((listener) => subscribeSidePanelLiveSnapshot(true, listener),
|
||||
() => getSidePanelLiveSnapshot(true).focusedSessionId, () => null);
|
||||
const hostRef = useRef(host); hostRef.current = host;
|
||||
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
|
||||
const pages = useMemo(() => collectPluginTerminalSidebarPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
|
||||
const sessionId = activeTabId && host.isTerminalTab(activeTabId) ? host.getSession() : null;
|
||||
useEffect(() => {
|
||||
const open = (event: Event) => {
|
||||
const detail = (event as CustomEvent<OpenPluginViewDetail & { sessionId?: string }>).detail;
|
||||
const page = pages.find((item) => item.viewId === detail?.viewId);
|
||||
if (!page) return;
|
||||
const tabId = activeTabStore.getActiveTabId();
|
||||
const current = hostRef.current.getSession();
|
||||
if (!hostRef.current.isTerminalTab(tabId) || !current || (detail.sessionId && detail.sessionId !== current)) {
|
||||
toast.info(t('plugins.sidebar.selectTerminal'));
|
||||
return;
|
||||
}
|
||||
hostRef.current.select(page.tool);
|
||||
};
|
||||
window.addEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open);
|
||||
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
|
||||
return () => { window.removeEventListener(OPEN_PLUGIN_TERMINAL_SIDEBAR_EVENT, open); window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open); };
|
||||
}, [pages, t]);
|
||||
useEffect(() => {
|
||||
if (contributions.loading) return;
|
||||
const allowed = new Set(pages.map((page) => page.tool));
|
||||
for (const [tabId, layout] of host.layouts) for (const pane of collectSidePanelPanes(layout.root)) {
|
||||
if (isPluginSidePanelTool(pane.tool) && !allowed.has(pane.tool)) host.withdraw(tabId, pane.id, pane.tool);
|
||||
}
|
||||
}, [contributions.loading, host, pages]);
|
||||
return { pages, contributions, tabId: activeTabId, sessionId };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { activeTabStore, useActiveTabId } from './activeTabStore';
|
||||
import { collectPluginVaultPages, PLUGIN_VAULT_PAGE_PREFIX, resolvePluginVaultPage } from './pluginVaultPages';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
|
||||
|
||||
const CONTEXT = Object.freeze({ 'netcatty.surface': 'vault-page' });
|
||||
|
||||
export function usePluginVaultPages({ locale, section, select }: {
|
||||
locale: string;
|
||||
section: string;
|
||||
select(section: 'hosts' | `plugin-vault:${string}`): void;
|
||||
}) {
|
||||
const active = useActiveTabId() === 'vault';
|
||||
const sectionRef = useRef(section);
|
||||
const focusFrame = useRef<number | null>(null);
|
||||
sectionRef.current = section;
|
||||
useEffect(() => () => { if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current); }, []);
|
||||
const contributions = usePluginContributions({ locale, context: CONTEXT });
|
||||
const pages = useMemo(() => collectPluginVaultPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
|
||||
const selected = resolvePluginVaultPage(pages, section);
|
||||
const fallback = useCallback(() => {
|
||||
select('hosts');
|
||||
if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current);
|
||||
focusFrame.current = requestAnimationFrame(() => {
|
||||
focusFrame.current = null;
|
||||
if (sectionRef.current === 'hosts' && activeTabStore.getActiveTabId() === 'vault') {
|
||||
document.querySelector<HTMLButtonElement>('[data-plugin-vault-fallback]')?.focus();
|
||||
}
|
||||
});
|
||||
}, [select]);
|
||||
useEffect(() => {
|
||||
if (!contributions.loading && section.startsWith(PLUGIN_VAULT_PAGE_PREFIX) && !selected) fallback();
|
||||
}, [contributions.loading, fallback, section, selected]);
|
||||
useEffect(() => {
|
||||
const open = (event: Event) => {
|
||||
const detail = (event as CustomEvent<OpenPluginViewDetail>).detail;
|
||||
const page = pages.find((item) => item.viewId === detail?.viewId);
|
||||
if (!page) return;
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
select(page.key);
|
||||
};
|
||||
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
|
||||
return () => window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
|
||||
}, [pages, select]);
|
||||
return { pages, selected, active, select, fallback, contributions, context: CONTEXT };
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function usePluginViewLifecycle({
|
||||
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) ?? null,
|
||||
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
|
||||
[effectiveRequested?.viewId, viewContributions.snapshot.plugins]);
|
||||
const stableActiveViewRef = useRef<PluginViewSnapshotSelection<ResolvedPluginView> | null>(null);
|
||||
const activeView = resolvePluginViewSnapshotSelection({
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('terminal extension pages use actual split panes, focus and session ownership without sharing session data', 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', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
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;
|
||||
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 { 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');
|
||||
const { usePluginTerminalSidebar } = await import('../application/state/usePluginTerminalSidebar');
|
||||
const { PluginTerminalSidebarLauncher, PluginTerminalSidebarPageHost } = await import('./plugins/PluginTerminalSidebar');
|
||||
const { SidePanelLayoutTree } = await import('./terminalLayer/TerminalLayerSidePanelSection');
|
||||
const { PersistentSidePanelPortal } = await import('./terminalLayer/terminalLayerSidePanelSlots');
|
||||
const { collectSidePanelPanes } = await import('../domain/sidePanelLayout');
|
||||
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 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>();
|
||||
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); },
|
||||
} });
|
||||
let split!: () => void;
|
||||
function Fixture() {
|
||||
const state = useTerminalSidePanelLayoutState(); const latest = useRef(state); latest.current = state;
|
||||
const model = usePluginTerminalSidebar({ layouts: state.sidePanelLayouts, getSession, isTerminalTab: (id) => id === 'workspace-synthetic' || id === 'synthetic-local-terminal',
|
||||
select: (tool) => state.selectSidePanelToolForTab(activeTabStore.getActiveTabId(), tool),
|
||||
withdraw: (tabId, paneId, tool) => {
|
||||
const layout = state.sidePanelLayoutsRef.current.get(tabId);
|
||||
if (layout && collectSidePanelPanes(layout.root).some((pane) => pane.id === paneId && pane.tool === tool)) state.closePane(tabId, paneId);
|
||||
} });
|
||||
const [hosts, setHosts] = useState(new Map<Tool, HTMLElement>()); const parking = useRef<HTMLDivElement>(null);
|
||||
const register = useCallback((tool: Tool, host: HTMLElement | null) => setHosts((prior) => {
|
||||
if ((prior.get(tool) ?? null) === host) return prior;
|
||||
const next = new Map(prior); if (host) next.set(tool, host); else next.delete(tool); return next;
|
||||
}), []);
|
||||
const focusPane = useCallback((id: string) => latest.current.focusPane(activeTabStore.getActiveTabId(), id), []);
|
||||
const closePane = useCallback((id: string) => { latest.current.closePane(activeTabStore.getActiveTabId(), id); }, []);
|
||||
const resize = useCallback(() => {}, []);
|
||||
const layout = model.tabId ? state.sidePanelLayouts.get(model.tabId) : null;
|
||||
const panes = layout ? collectSidePanelPanes(layout.root) : [];
|
||||
split = () => state.splitPane('workspace-synthetic', `plugin-view:${b.id}`, 'vertical', { paneId: 'second-pane', splitId: 'plugin-split' }, 600);
|
||||
return <><PluginTerminalSidebarLauncher sessionId={selectedSession} />
|
||||
{layout && <SidePanelLayoutTree node={layout.root} layout={layout} paneCount={panes.length} labels={new Map(model.pages.map((page) => [page.tool, page.title]))}
|
||||
onClose={closePane} onFocus={focusPane} onHostChange={register} onResize={resize} separator="#777" accent="#4488cc" closePaneLabel="Close pane" resizeLabel="Resize" />}
|
||||
<div ref={parking} hidden aria-hidden="true" />
|
||||
{model.sessionId && panes.map((pane) => {
|
||||
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)} />
|
||||
</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 () => { 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(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(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(); });
|
||||
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(); });
|
||||
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(); });
|
||||
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(); });
|
||||
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'))!;
|
||||
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();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('Vault navigation mounts a real content slot, yields to host dialogs, and withdraws stale pages', 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>();
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', '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 Window] });
|
||||
}
|
||||
for (const [key, value] of Object.entries({ getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
|
||||
requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), IS_REACT_ACT_ENVIRONMENT: true })) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 200, y: 100, width: 640, height: 480, top: 100, left: 200, right: 840, bottom: 580, toJSON() {} });
|
||||
const { default: React, act, useState } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { usePluginVaultPages } = await import('../application/state/usePluginVaultPages');
|
||||
const { PluginVaultNavigation, PluginVaultPage } = await import('./plugins/PluginVaultPages');
|
||||
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 };
|
||||
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();
|
||||
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(); },
|
||||
} });
|
||||
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
|
||||
function Fixture() {
|
||||
const [section, select] = useState<'hosts' | `plugin-vault:${string}`>('hosts'); selectSection = select;
|
||||
const model = usePluginVaultPages({ locale: 'en', section, select });
|
||||
return <><aside><button data-plugin-vault-fallback onClick={() => select('hosts')}>Hosts</button><PluginVaultNavigation model={model} collapsed={false} /></aside>
|
||||
<main data-section="vault-main">{model.selected ? <PluginVaultPage key={model.selected.key} model={model} /> : <p>Built-in hosts</p>}</main></>;
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 35));
|
||||
const clickPage = async () => { await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Synthetic page — Synthetic plugin"]')!.click(); await settle(); }); };
|
||||
try {
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
|
||||
assert.equal(opens.length, 0, 'catalog enumeration does not activate the plugin');
|
||||
await clickPage();
|
||||
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('vault'); await settle(); });
|
||||
assert.equal(opens.length, 2);
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
|
||||
await act(async () => { await settle(); });
|
||||
assert.ok(closes.includes(opens[1].instanceId!));
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
|
||||
assert.equal(document.activeElement?.textContent, 'Hosts');
|
||||
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
|
||||
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
|
||||
deferOpen = true;
|
||||
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
|
||||
assert.equal(opens.length, 3);
|
||||
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');
|
||||
} 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); }
|
||||
}
|
||||
});
|
||||
@@ -123,6 +123,7 @@ import {
|
||||
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../domain/host';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
isPluginSidePanelTool,
|
||||
sidePanelLayoutHasTool,
|
||||
type SidePanelLayout,
|
||||
type SidePanelSplitDirection,
|
||||
@@ -1933,6 +1934,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
resizeSidePanelSplitForTab(tabId, splitId, sizes);
|
||||
}, [resizeSidePanelSplitForTab]);
|
||||
|
||||
const handleWithdrawPluginSidebarPane = useCallback((tabId: string, paneId: string, expectedTool: SidePanelTab) => {
|
||||
if (!isPluginSidePanelTool(expectedTool)) return;
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
const pane = layout && collectSidePanelPanes(layout.root).find((item) => item.id === paneId);
|
||||
if (pane?.tool !== expectedTool) return;
|
||||
setMagnifiedPane((current) => current?.tabId === tabId && current.target.kind === 'side-panel'
|
||||
&& current.target.paneId === paneId ? null : current);
|
||||
closeSidePanelPaneForTab(tabId, paneId);
|
||||
}, [closeSidePanelPaneForTab, sidePanelLayoutsRef]);
|
||||
|
||||
// Toggle SFTP from activity bar header
|
||||
const handleToggleSftpFromBar = useCallback(() => {
|
||||
handleSwitchSidePanelTab('sftp');
|
||||
@@ -2617,6 +2628,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleOpenAI,
|
||||
handleOpenSystem,
|
||||
handleOpenNotes,
|
||||
getActiveTerminalSessionId,
|
||||
handleSwitchSidePanelTab,
|
||||
handleWithdrawPluginSidebarPane,
|
||||
handleOpenBrowser,
|
||||
handleFocusSidePanelPane,
|
||||
handleMagnifySidePanelPane,
|
||||
|
||||
@@ -161,6 +161,7 @@ import { VaultViewLayout } from "./vault/VaultViewLayout";
|
||||
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
|
||||
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
|
||||
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
|
||||
import { usePluginVaultPages } from "../application/state/usePluginVaultPages";
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
@@ -178,7 +179,8 @@ export type VaultSection =
|
||||
| "notes"
|
||||
| "port"
|
||||
| "knownhosts"
|
||||
| "logs";
|
||||
| "logs"
|
||||
| `plugin-vault:${string}`;
|
||||
|
||||
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
|
||||
if (previous.length !== next.length) return false;
|
||||
@@ -365,11 +367,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onVaultFocusRequestHandled,
|
||||
terminalSettings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
||||
const pluginVault = usePluginVaultPages({ locale, section: currentSection, select: setCurrentSection });
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
||||
null,
|
||||
@@ -1522,6 +1525,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
ContextMenuTrigger,
|
||||
Copy,
|
||||
currentSection,
|
||||
pluginVault,
|
||||
customGroups,
|
||||
deleteGroupPath,
|
||||
deleteGroupPaths,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
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';
|
||||
|
||||
export function PluginTerminalSidebarLauncher({ sessionId }: { sessionId: string }) {
|
||||
const { locale, t } = useI18n();
|
||||
const contributions = usePluginContributions({ locale, context: PLUGIN_TERMINAL_SIDEBAR_CONTEXT });
|
||||
const pages = collectPluginTerminalSidebarPages(contributions.snapshot.plugins);
|
||||
if (!pages.length) return null;
|
||||
return <Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6" aria-label={t('plugins.sidebar.extensions')}><Blocks size={14} /></Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="max-h-80 w-64 overflow-y-auto p-1">
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t('plugins.sidebar.extensions')}</p>
|
||||
{pages.map((page) => <PopoverClose asChild key={page.tool}><button type="button" className="flex w-full items-center gap-2 rounded px-2 py-2 text-left text-sm hover:bg-accent"
|
||||
onClick={() => requestOpenPluginTerminalSidebar(page.viewId, sessionId)}>
|
||||
<PluginContributionIcon pluginId={page.pluginId} icon={page.icon} /><span className="min-w-0"><span className="block truncate">{page.title}</span><span className="block truncate text-xs text-muted-foreground">{page.pluginName}</span></span>
|
||||
</button></PopoverClose>)}
|
||||
</PopoverContent></Popover>;
|
||||
}
|
||||
|
||||
export function PluginTerminalSidebarPageHost({ page, tabId, sessionId, adapter, getSession, unavailable, focused, focusOnReveal }: {
|
||||
page: PluginTerminalSidebarPage;
|
||||
tabId: string;
|
||||
sessionId: string;
|
||||
adapter: ReturnType<typeof usePluginContributions>;
|
||||
getSession(): string | null;
|
||||
unavailable(): void;
|
||||
focused(): void;
|
||||
focusOnReveal: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const subscribeHidden = useMemo(() => (hide: () => void) => {
|
||||
const stopTab = activeTabStore.subscribeSync((id) => { if (id !== tabId) hide(); });
|
||||
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>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { activeTabStore } from '../../application/state/activeTabStore';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useEmbeddedPluginView } from '../../application/state/useEmbeddedPluginView';
|
||||
import type { usePluginVaultPages } from '../../application/state/usePluginVaultPages';
|
||||
import { PluginContributionIcon } from './PluginContributionIcon';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
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>}
|
||||
{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'}`}
|
||||
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" />
|
||||
{!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>
|
||||
</section>;
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import { collectOwnedPluginMenus, comparePluginMenus, usePluginContributions } f
|
||||
import { buildTerminalPluginContributionContext } from '../../application/state/pluginContributionContexts';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
import { isPluginHostProtocol } from '../../domain/pluginConnection';
|
||||
import { PluginTerminalSidebarLauncher } from '../plugins/PluginTerminalSidebar';
|
||||
|
||||
export const TERMINAL_TOOLBAR_ITEM_IDS = [
|
||||
'highlight',
|
||||
@@ -1158,6 +1159,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
)}
|
||||
</ToolbarCustomizeContextMenu>
|
||||
|
||||
{!compactToolbar && <PluginTerminalSidebarLauncher sessionId={sessionId} />}
|
||||
{pluginToolbarMenus.map((menu) => (
|
||||
<Tooltip key={menu.id}>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -9,7 +9,7 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef,
|
||||
import { createPortal } from 'react-dom';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
|
||||
import { useActiveTabId } from '../../application/state/activeTabStore';
|
||||
import { activeTabStore, useActiveTabId } from '../../application/state/activeTabStore';
|
||||
import {
|
||||
getSidePanelLiveSnapshot,
|
||||
subscribeSidePanelLiveSnapshot,
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
MAX_SIDE_PANEL_PANES,
|
||||
canSplitSidePanelPaneAtSize,
|
||||
collectSidePanelPanes,
|
||||
isPluginSidePanelTool,
|
||||
getFocusedSidePanelPane,
|
||||
getSidePanelNodeMinimumPixels,
|
||||
getSidePanelSplitResizeBounds,
|
||||
@@ -60,7 +61,10 @@ import {
|
||||
type SidePanelSplitNode,
|
||||
} from '../../domain/sidePanelLayout';
|
||||
import { terminalLayerSidePanelStableCtxEqual } from './terminalLayerViewMemo';
|
||||
import { SidePanelMountedContent } from './terminalLayerSidePanelSlots';
|
||||
import { PersistentSidePanelPortal, SidePanelMountedContent } from './terminalLayerSidePanelSlots';
|
||||
import { usePluginTerminalSidebar } from '../../application/state/usePluginTerminalSidebar';
|
||||
import { PluginTerminalSidebarPageHost } from '../plugins/PluginTerminalSidebar';
|
||||
import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
|
||||
import { getPaneMagnificationShortcutLabel } from '../../domain/paneMagnification';
|
||||
|
||||
const MemoizedSidePanelMountedContent = memo(
|
||||
@@ -358,7 +362,7 @@ function SidePanelSplitView({
|
||||
);
|
||||
}
|
||||
|
||||
function SidePanelLayoutTree({
|
||||
export function SidePanelLayoutTree({
|
||||
node,
|
||||
layout,
|
||||
paneCount,
|
||||
@@ -568,6 +572,9 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
|
||||
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('history');
|
||||
const anyNotesOpen = sidePanelOpenTabs instanceof Map
|
||||
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('notes');
|
||||
const anyPluginOpen = ctx.sidePanelLayouts instanceof Map
|
||||
&& [...(ctx.sidePanelLayouts as Map<string, SidePanelLayout>).values()]
|
||||
.some((layout) => collectSidePanelPanes(layout.root).some((pane) => isPluginSidePanelTool(pane.tool)));
|
||||
|
||||
return !(
|
||||
browserMountedTabIds.length === 0
|
||||
@@ -579,10 +586,16 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
|
||||
&& themeMountedTabIds.length === 0
|
||||
&& !anyHistoryOpen
|
||||
&& !anyNotesOpen
|
||||
&& !anyPluginOpen
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalLayerSidePanelSectionInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
const pluginSidebar = usePluginTerminalSidebar({ layouts: ctx.sidePanelLayouts ?? new Map(),
|
||||
getSession: ctx.getActiveTerminalSessionId ?? (() => null),
|
||||
isTerminalTab: (tabId) => (ctx.sessions ?? []).some((session: { id: string }) => session.id === tabId) || !!ctx.workspaceById?.has(tabId),
|
||||
select: (tool) => ctx.handleSwitchSidePanelTab?.(tool),
|
||||
withdraw: (tabId, paneId, tool) => ctx.handleWithdrawPluginSidebarPane?.(tabId, paneId, tool) });
|
||||
if (!hasMountedSidePanelContent(ctx)) {
|
||||
// Keep AI scope maintenance alive even with zero mounted tool panels so
|
||||
// merge/dissolve handoff still runs when the user closed AI before the
|
||||
@@ -604,7 +617,7 @@ function TerminalLayerSidePanelSectionInner({ ctx }: { ctx: SidePanelContext })
|
||||
</AISidePanelStateRoot>
|
||||
);
|
||||
}
|
||||
return <TerminalLayerSidePanelInner ctx={ctx} />;
|
||||
return <TerminalLayerSidePanelInner ctx={ctx} pluginSidebar={pluginSidebar} />;
|
||||
}
|
||||
|
||||
/** Skip chrome rebuilds when only live/workspace-focus ticks change. */
|
||||
@@ -614,7 +627,7 @@ export const TerminalLayerSidePanelSection = memo(
|
||||
);
|
||||
TerminalLayerSidePanelSection.displayName = 'TerminalLayerSidePanelSection';
|
||||
|
||||
function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
function TerminalLayerSidePanelInner({ ctx, pluginSidebar }: { ctx: SidePanelContext; pluginSidebar: ReturnType<typeof usePluginTerminalSidebar> }) {
|
||||
const activeTabId = useActiveTabId();
|
||||
const sidePanelOpenTabs = ctx.sidePanelOpenTabs as Map<string, SidePanelTab>;
|
||||
const sidePanelLayouts = ctx.sidePanelLayouts as Map<string, SidePanelLayout>;
|
||||
@@ -630,6 +643,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenAI,
|
||||
handleOpenHistory,
|
||||
handleOpenNotes,
|
||||
handleSwitchSidePanelTab,
|
||||
handleOpenBrowser,
|
||||
handleOpenScripts,
|
||||
handleOpenSystem,
|
||||
@@ -1046,6 +1060,9 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
{ id: 'system' as const, label: t('terminal.layer.system'), icon: <Activity size={15} />, onClick: handleOpenSystem },
|
||||
{ id: 'notes' as const, label: t('terminal.layer.notes'), icon: <NotebookText size={15} />, onClick: handleOpenNotes },
|
||||
{ id: 'ai' as const, label: t('terminal.layer.aiChat'), icon: <MessageSquare size={15} />, onClick: handleOpenAI },
|
||||
...pluginSidebar.pages.map((page) => ({ id: page.tool, label: `${page.title} · ${page.pluginName}`,
|
||||
icon: <PluginContributionIcon pluginId={page.pluginId} icon={page.icon} size={15} />,
|
||||
onClick: () => handleSwitchSidePanelTab?.(page.tool) })),
|
||||
], [
|
||||
handleOpenAI,
|
||||
handleOpenHistory,
|
||||
@@ -1056,6 +1073,8 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
handleOpenTheme,
|
||||
handleToggleSftpFromBar,
|
||||
t,
|
||||
handleSwitchSidePanelTab,
|
||||
pluginSidebar.pages,
|
||||
]);
|
||||
const sidePanelTabItemById = useMemo(
|
||||
() => new Map(sidePanelTabItems.map((item) => [item.id, item])),
|
||||
@@ -1491,6 +1510,19 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
|
||||
paneHosts={panePortalHosts}
|
||||
parkingHost={parkingHost}
|
||||
/>
|
||||
{activeTabId && pluginSidebar.sessionId && activeSidePanelPanes.map((pane) => {
|
||||
const page = pluginSidebar.pages.find((item) => item.tool === pane.tool);
|
||||
const target = panePortalHosts.get(pane.tool) ?? parkingHost;
|
||||
if (!page || !target) return null;
|
||||
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}
|
||||
unavailable={() => ctx.handleWithdrawPluginSidebarPane?.(activeTabId, pane.id, page.tool)}
|
||||
focused={() => { if (activeTabStore.getActiveTabId() === activeTabId) ctx.handleFocusSidePanelPane?.(pane.id); }} />
|
||||
</PersistentSidePanelPortal>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,13 @@ test('terminal layer bridge passes vault open callbacks into the side panel cont
|
||||
assert.match(source, /onOpenVaultSectionFromChat: s\.onOpenVaultSectionFromChat/);
|
||||
});
|
||||
|
||||
test('production terminal layer context forwards extension sidebar owner and pane callbacks', () => {
|
||||
const contextProjection = source.slice(source.indexOf('const ctx = useMemo('));
|
||||
for (const callback of ['getActiveTerminalSessionId', 'handleSwitchSidePanelTab', 'handleWithdrawPluginSidebarPane']) {
|
||||
assert.match(contextProjection, new RegExp(`${callback}: s\\.${callback}\\b`));
|
||||
}
|
||||
});
|
||||
|
||||
test('terminal layer bridge updates side panel live state after render', () => {
|
||||
assert.match(source, /const sidePanelLiveSnapshot = useMemo<SidePanelLiveSnapshot>/);
|
||||
assert.match(
|
||||
|
||||
@@ -489,6 +489,9 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
|
||||
handleFontWeightResetForFocusedSession: themeState.handleFontWeightResetForFocusedSession,
|
||||
handleOpenAI: s.handleOpenAI,
|
||||
handleOpenNotes: s.handleOpenNotes,
|
||||
getActiveTerminalSessionId: s.getActiveTerminalSessionId,
|
||||
handleSwitchSidePanelTab: s.handleSwitchSidePanelTab,
|
||||
handleWithdrawPluginSidebarPane: s.handleWithdrawPluginSidebarPane,
|
||||
handleOpenBrowser: s.handleOpenBrowser,
|
||||
handleOpenSystem: s.handleOpenSystem,
|
||||
handleOpenHistory: s.handleOpenHistory,
|
||||
|
||||
@@ -7,8 +7,9 @@ import { TerminalLayerWorkspaceSection } from './TerminalLayerWorkspaceSection';
|
||||
import { terminalLayerViewCtxEqual } from './terminalLayerViewMemo';
|
||||
import { useTerminalHostTreeLayoutWidth } from '../../application/state/terminalHostTreeStore';
|
||||
import { resolveTerminalLayerSurfaceStyle } from '../terminalPaneVisibility';
|
||||
import type { PluginSidebarHostActions } from '../../application/state/usePluginTerminalSidebar';
|
||||
|
||||
type TerminalLayerViewContext = Record<string, any>;
|
||||
type TerminalLayerViewContext = Record<string, any> & PluginSidebarHostActions;
|
||||
|
||||
function TerminalLayerViewInner({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
const hostTreeLayoutWidth = useTerminalHostTreeLayoutWidth();
|
||||
|
||||
@@ -693,7 +693,7 @@ export function movePersistentPortalNode(
|
||||
}
|
||||
}
|
||||
|
||||
function PersistentSidePanelPortal({
|
||||
export function PersistentSidePanelPortal({
|
||||
portalKey,
|
||||
target,
|
||||
children,
|
||||
|
||||
@@ -316,6 +316,9 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
|
||||
'handleOpenTheme',
|
||||
'handleOpenAI',
|
||||
'handleOpenNotes',
|
||||
'getActiveTerminalSessionId',
|
||||
'handleSwitchSidePanelTab',
|
||||
'handleWithdrawPluginSidebarPane',
|
||||
'handleOpenBrowser',
|
||||
'handleBackFromNotes',
|
||||
'handleOpenHostFromNotes',
|
||||
|
||||
@@ -29,6 +29,7 @@ import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
|
||||
import { toast } from "../ui/toast";
|
||||
import { AppWordmark } from "../AppWordmark";
|
||||
import { openHostBrowser } from "../../application/hostBrowser";
|
||||
import { PluginVaultNavigation, PluginVaultPage } from '../plugins/PluginVaultPages';
|
||||
|
||||
type VaultViewLayoutContext = Record<string, any>;
|
||||
|
||||
@@ -156,6 +157,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
ContextMenuTrigger,
|
||||
Copy,
|
||||
currentSection,
|
||||
pluginVault,
|
||||
customGroups,
|
||||
deleteGroupPaths,
|
||||
deleteGroupWithHosts,
|
||||
@@ -595,12 +597,13 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn("space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
|
||||
className={cn("min-h-0 flex-1 overflow-y-auto space-y-1", sidebarCollapsed ? "px-1.5" : "px-2.5")}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "hosts" ? "secondary" : "ghost"}
|
||||
data-plugin-vault-fallback
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed
|
||||
@@ -805,11 +808,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
{pluginVault && <PluginVaultNavigation model={pluginVault} collapsed={sidebarCollapsed} />}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"mt-auto pb-4 space-y-2",
|
||||
"mt-auto shrink-0 pb-4 space-y-2",
|
||||
sidebarCollapsed ? "px-1.5" : "px-2.5",
|
||||
)}
|
||||
>
|
||||
@@ -864,6 +868,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
className="flex-1 min-w-0 flex flex-col min-h-0 relative"
|
||||
data-section="vault-main"
|
||||
>
|
||||
{pluginVault?.selected && <PluginVaultPage key={pluginVault.selected.key} model={pluginVault} />}
|
||||
<VaultPageHeader
|
||||
className={cn(!isHostsSectionActive && "hidden")}
|
||||
dataSection="vault-hosts-header"
|
||||
|
||||
@@ -17,7 +17,7 @@ mean complete. Existing APIs remain available.
|
||||
| Terminal providers | Decorations, links, completion, snapshots through provider requests | Session change delivery follows prior provider request and non-once grant | Preserve providers. Standalone selected-session watch planned | Hostnames, session state, endpoint identity | Host-selected session, runtime identity | Session-close and revoke cleanup, bounded event rate | Existing provider tests. Ordinary views cannot independently subscribe today |
|
||||
| Current context and host picker | Host context keys are boolean state | `vault.metadata` enumeration is not generic SDK | Selected host/session DTO from an explicit whitelist | Labels and endpoint metadata are sensitive | Host-owned user-action context, selected host/session | TTL, runtime and session invalidation, redacted audit | Planned. Never reuse agent `sanitizeHostForAgent` as plugin DTO |
|
||||
| Terminal selection and input draft | Provider-supplied snapshots, completion suggestions | No explicit selection-share or draft API | User shares selection, plugin proposes a visible editable draft | Terminal plaintext, accidental execution | Host action token plus exact live session/endpoint | Single-use token, size cap, no plaintext audit, active identity before commit | Planned. Reject Enter/control characters for draft insertion. No full history or direct shell |
|
||||
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested; native acceptance/review/CI pending. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
|
||||
| Native interactions and clipboard | Native permission dialogs; new browser SDK host choice/input and explicit copy/share workflow | Progress/toast remain unimplemented | Host-owned prompts, two-phase clipboard snapshot/share, exact copy preview | Clipboard and input may contain secrets; UI discloses granted network capability; prompt spam | `ui.interact`, clipboard direction, host-signed window/runtime/version/phase/TTL action | Admission before permission prompt, byte/rate/concurrency limits, lock/blur/close/revoke cleanup, active guard, content-free audit | [Host interaction slice](./host-interactions.md) implemented and synthetic-tested. 8e4dd0c exact test/packaging CI and independent delta review passed. Parent native compose and explicit Preview/Share/copy succeeded; a separately approved 0.1.1 compose ran about 86 seconds, beyond the old 30-second deadline and within the declared 180-second budget, with no fresh command-timeout error. All five permissions were Once; cleanup is parent-owned. New navigation slices retain separate acceptance gates. Progress/toast still planned. No background reads or trusted plugin `userGesture` boolean |
|
||||
| Local filesystem | Read file, stat, overwrite existing regular file | Directory listing lacks same-handle native adapter | Keep current boundary, evaluate file picker handles | Arbitrary local file content and writes | Canonical exact path or selected file handle | Inode checks, no symlink alias, bounded bytes, active runtime | Existing filesystem tests. Directory enumeration and new arbitrary-path creation deferred until portable safe native adapter exists |
|
||||
| SFTP and remote files | No generic plugin API | `sftp.read` and `sftp.write` enumerated only | Host-selected session/path/mode/TTL handles, read and explicit save/upload | Remote secrets and destructive overwrite | Live endpoint identity, exact path, read/write split | Cancel streams, byte quota, overwrite confirmation, close/revoke cleanup | Batch 2 planned. Never forward arbitrary MCP catalog or session ID alone |
|
||||
| Editor and Notes | No plugin document API | No versioned snapshot or edit preview | Explicit snapshot and versioned proposed edit | Document secrets, lost updates, executable files | Selected document plus revision and action token | Version conflict check, preview, bounded bytes, revocation | Planned evaluation after shared action context. No unattended edits |
|
||||
@@ -32,7 +32,7 @@ mean complete. Existing APIs remain available.
|
||||
| AI, MCP, automation | Host agent catalog and harness exist separately | No generic plugin AI or tool forwarding | Selection-to-proposal workflow through the same controlled primitives | Content disclosure, account authority and model cost | Explicit content sharing plus service origin/budget | No inherited history, tools, account, or hidden cron | Controlled workflow planned. Generic host AI and visible schedules deferred to distinct product decision |
|
||||
| Known hosts, app lock and global security | Host security management UI | No plugin management API | Future allowlisted security-page navigation | Host-key trust, unlock, keys and security policy affect all sessions | Host navigation action only | No mutation or sensitive DTO | Security policy mutation, private-key reads, auto-trust and unlocking deferred indefinitely |
|
||||
| Plugin blob/cache | Local KV exists | No exclusive blob cache | Evaluate bounded plugin-owned cache when a real workflow needs it | Disk growth and lifecycle ambiguity | Plugin namespace, no arbitrary path | Size quota, eviction and uninstall policy required | Deferred until a concrete larger-than-KV workflow establishes value and retention policy |
|
||||
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | Planned after host interaction/selected-context workflow. A button that merely opens a top tab does not satisfy Vault-page acceptance. No arbitrary router/DOM API |
|
||||
| Vault and terminal navigation | `tab` mounts a sandbox page through TopTabs/QuickSwitcher; aside/panel are overlays | No Vault subpage registry or terminal side-panel plugin tool. `settings` is a detail entry, not a new settings category | Accepted independent slice: host-owned namespaced Vault pages in an Extensions group, mounted in the Vault content area through existing ViewHost; then terminal pane/split pages for the selected session | Navigation cannot disclose session contents or replace built-in/security navigation | Declared page contribution plus host selection. Content reads have independent permissions | Host order/when/focus, cancellation of late open, view cleanup on hide/update/disable/uninstall, safe invalid-link fallback | [Vault page slice](./vault-pages.md) implemented and synthetic-tested: actual Vault content slot, private Scratchpad, catalog withdrawal, host occlusion, reserved-open cancellation, owner/crash/lock cleanup. Exact CI/independent/native acceptance pending. [Terminal sidebar slice](./terminal-sidebar.md) also implemented with actual pane/split mounting, owner focus and session-change cleanup; no automatic session data. Exact CI/independent/native acceptance pending for the new stack. No arbitrary router/DOM API |
|
||||
| Distribution and runtimes | Browser-only public catalog, immutable packages, advanced developer gate | Signing/trust and release remain separate phase | Retain existing boundary | Package execution and publisher trust | Pinned catalog selection, hash, engine/features | Existing install transaction, quarantine and cleanup | No merge/release. Do not extend to arbitrary Node, IPC, shell, or global capabilities |
|
||||
|
||||
## Delivery order and stopping rule
|
||||
@@ -83,3 +83,31 @@ Baseline code references: `electron/plugins/hostService.cjs`,
|
||||
implementations under `electron/capabilities/catalog/` are reference material,
|
||||
not plugin authority. `infrastructure/ai/vaultAgentBridgeClient.ts` is not a
|
||||
plugin DTO sanitizer.
|
||||
|
||||
## Navigation slice checkpoint
|
||||
|
||||
The Vault stage adds `views.vault-pages`, the `vault-page` location, and the
|
||||
existing `views` permission. It grants no Vault/session data. The ordinary
|
||||
browser Scratchpad example uses only its own bounded view-state API and
|
||||
explicit user input. It is not a Notes API or automatic sync feature.
|
||||
|
||||
The independent integration base includes #3956 UI polish and #3960 d4ca fixes;
|
||||
frozen evidence heads and prior PR bases are unchanged. Related UI changes in
|
||||
this stage are limited to VaultView/VaultViewLayout, new independent plugin
|
||||
components/hooks, and localized strings. PluginMarketplace/PluginPackageManager
|
||||
layouts are not rewritten. Native acceptance belongs to the parent with a new
|
||||
fixture-specific own-views approval. Sidebar, ThemePack, selected context and
|
||||
SFTP remain unfinished; this checkpoint is not overall completion.
|
||||
|
||||
## Terminal sidebar checkpoint
|
||||
|
||||
`views.terminal-sidebar` exposes one more existing sandbox view placement,
|
||||
using only the existing own-views permission. The host owns navigation,
|
||||
selection, split/close and focus. Its context is a surface string only; no
|
||||
terminal DTO or resource authority is implied. The Session Scratchpad fixture
|
||||
uses explicitly typed synthetic text and its own view state.
|
||||
|
||||
This stage builds on ordinary merges containing #3960 8e4dd0c and the #3971
|
||||
stale-layout callback fix. Prior QA heads and PR bases are not rewritten. The
|
||||
ThemePack implementation, selected-context/drafts and file handles remain
|
||||
unfinished; parent review/native gates still apply to each delivered head.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Terminal sidebar pages
|
||||
|
||||
The `terminal-sidebar` view location requires `views.terminal-sidebar` and the
|
||||
existing `views` permission. This adds navigation and plugin-owned content to
|
||||
the existing terminal pane/split system. It is not a session-data capability,
|
||||
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;
|
||||
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
|
||||
tools. Declared order and ID determine listing order; existing host docking,
|
||||
split resizing, magnification and close controls remain authoritative.
|
||||
|
||||
The manager's internal Open event can select a page only when an open terminal
|
||||
is already selected. Otherwise the host asks the user to select a terminal; it
|
||||
does not connect or open a saved host. Launcher events target the session from
|
||||
the actual host terminal toolbar and are rechecked against current selection.
|
||||
These are internal host UI events, not public SDK router methods or trusted
|
||||
plugin `userGesture` flags.
|
||||
|
||||
## Session ownership without data sharing
|
||||
|
||||
The renderer binds each page to the actual terminal tab/workspace and current
|
||||
focused child session. It passes only `netcatty.surface: terminal-sidebar` as
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,80 @@
|
||||
# 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.
|
||||
|
||||
## User workflow
|
||||
|
||||
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.
|
||||
|
||||
Selecting a page mounts its existing sandbox WebContentsView in the Vault main
|
||||
content area. It does not create a top tab, use an aside overlay, open a
|
||||
connection, or share host/session/Vault records. The trusted host context
|
||||
contains only `netcatty.surface: vault-page`. The manager's existing Open action
|
||||
can select the same page through an internal host event; this is not exposed
|
||||
as a generic SDK navigation capability.
|
||||
|
||||
The `vault-scratchpad` browser example lets the user type synthetic text, save
|
||||
it in the plugin's own view state, return to Hosts, reopen, and clear it. It
|
||||
does not create a Vault Note or enable automatic cloud sync. The fixture asks
|
||||
for only `views`; granting it does not approve another plugin or data access.
|
||||
|
||||
## Ownership and lifecycle
|
||||
|
||||
The host owns the route and validates the selected contribution against the
|
||||
current visible catalog. Missing IDs, disable/uninstall/update, a false `when`,
|
||||
failed activation or a host close return to Hosts. Focus restoration checks
|
||||
the current section and active tab so an old fallback cannot steal focus after
|
||||
a newer selection. Catalog listing does not activate a plugin.
|
||||
|
||||
The embedded host reuses PluginViewHost, its ephemeral session, CSP, no-Node
|
||||
preload, protocol validation, own-command checks, and bounded own view state.
|
||||
No DOM or Electron object enters the SDK. A window-route-and-slot scope separates
|
||||
this page's state from other view placements; actual BrowserWindow ownership is
|
||||
validated separately and does not rely on that state key. This initial embedded slice
|
||||
closes on navigation away even if `retainContextWhenHidden` is declared.
|
||||
|
||||
Each opening reserves an instance ID owned by its actual BrowserWindow before
|
||||
asynchronous activation. Close can cancel that reservation before native
|
||||
construction. Owner closure, shutdown and renderer cleanup prevent a late open
|
||||
from attaching; late responses are also closed by the renderer lifecycle
|
||||
controller. Current plugin version/runtime and `when` are checked before
|
||||
attachment and on subsequent contribution changes. Existing owner checks apply
|
||||
to bounds, visibility, close and view-state IPC.
|
||||
|
||||
Host reload/renderer exit and a view renderer crash close the associated native
|
||||
page. Plain Escape in a Vault page is handled by the host and returns to Hosts;
|
||||
it does not dispatch a plugin command or expose a general navigation API.
|
||||
|
||||
Native child views sit above React portals. A Vault page starts hidden; the
|
||||
trusted renderer reveals it only after checking the mount and host dialogs,
|
||||
menus, popovers, overlay panels and lock surfaces. Overlapping host surfaces
|
||||
hide the page. App Lock additionally closes all hosted plugin views and
|
||||
cancels pending opens in the main process; showing a view while locked fails.
|
||||
|
||||
The shared manifest bound remains 128 views per package. Only one Vault page is
|
||||
mounted per owner; listing and routing do not create a background view queue.
|
||||
View-state JSON size limits and existing runtime/permission quotas remain in
|
||||
force. No page text or state is added to audit logs by this adapter.
|
||||
|
||||
## Validation status
|
||||
|
||||
Source tests cover normal catalog selection, the actual embedded content slot,
|
||||
host-modal occlusion, synchronous tab-away cleanup, withdrawn and invalid
|
||||
routes, internal host selection, delayed open cancellation, feature/permission
|
||||
admission, host ownership, `when` withdrawal and App Lock. Production build,
|
||||
aggregate host tests and precise-SHA CI are reported on the accompanying Draft
|
||||
PR. Parent native acceptance must use the packed browser example through
|
||||
normal Settings and record its exact source SHA/package hash. Unit tests do
|
||||
not substitute for that native acceptance.
|
||||
|
||||
Terminal sidebar pages and selected-session DTOs remain separate stages. This
|
||||
page location does not make those capabilities available.
|
||||
@@ -1,4 +1,4 @@
|
||||
export type SidePanelTool =
|
||||
export type BuiltinSidePanelTool =
|
||||
| 'sftp'
|
||||
| 'browser'
|
||||
| 'scripts'
|
||||
@@ -8,6 +8,12 @@ export type SidePanelTool =
|
||||
| 'system'
|
||||
| 'notes';
|
||||
|
||||
export type SidePanelTool = BuiltinSidePanelTool | `plugin-view:${string}`;
|
||||
|
||||
export function isPluginSidePanelTool(value: unknown): value is `plugin-view:${string}` {
|
||||
return typeof value === 'string' && value.length <= 256 && /^plugin-view:[A-Za-z0-9_.-]+$/.test(value);
|
||||
}
|
||||
|
||||
export type SidePanelSplitDirection = 'horizontal' | 'vertical';
|
||||
|
||||
export type SidePanelPaneNode = {
|
||||
|
||||
@@ -90,6 +90,21 @@ test('sanitize accepts a valid saved preset and rejects malformed data', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('plugin panes keep split uniqueness and limits; saved routes grant no availability and can be pruned', () => {
|
||||
const tool = 'plugin-view:com.example.plugin.page' as const;
|
||||
let layout = createSidePanelLayout('notes', 'notes');
|
||||
layout = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'plugin', splitId: 'root' }, 600);
|
||||
const unique = splitSidePanelPane(layout, 'notes', tool, 'vertical', { paneId: 'duplicate', splitId: 'unused' }, 600);
|
||||
assert.equal(collectSidePanelPanes(unique.root).length, 2);
|
||||
assert.equal(getFocusedSidePanelPane(unique).tool, tool);
|
||||
assert.ok(createWorkspaceLayoutPreset(layout));
|
||||
assert.equal(withoutUnavailableSidePanelTools(layout, (item) => item === 'notes')?.root.type, 'pane');
|
||||
for (let i = 0; i < 10; i++) layout = splitSidePanelPane(layout, getFocusedSidePanelPane(layout).id,
|
||||
`plugin-view:com.example.plugin.page${i}`, 'horizontal', { paneId: `p${i}`, splitId: `s${i}` }, 600);
|
||||
assert.equal(collectSidePanelPanes(layout.root).length, 8);
|
||||
assert.equal(sanitizeWorkspaceLayoutPreset({ version: 1, layout: { root: { id: 'invalid', type: 'pane', tool: 'plugin-view:../outside' }, focusedPaneId: 'invalid' } }), null);
|
||||
});
|
||||
|
||||
test('sanitize rejects duplicate node ids', () => {
|
||||
assert.equal(
|
||||
sanitizeWorkspaceLayoutPreset({
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
closeSidePanelPane,
|
||||
collectSidePanelPanes,
|
||||
getFocusedSidePanelPane,
|
||||
isPluginSidePanelTool,
|
||||
type SidePanelLayout,
|
||||
type SidePanelLayoutNode,
|
||||
type SidePanelTool,
|
||||
@@ -32,7 +33,7 @@ const SIDE_PANEL_TOOLS: ReadonlySet<SidePanelTool> = new Set([
|
||||
]);
|
||||
|
||||
function isSidePanelTool(value: unknown): value is SidePanelTool {
|
||||
return typeof value === 'string' && SIDE_PANEL_TOOLS.has(value as SidePanelTool);
|
||||
return typeof value === 'string' && (SIDE_PANEL_TOOLS.has(value as SidePanelTool) || isPluginSidePanelTool(value));
|
||||
}
|
||||
|
||||
function sanitizeSidePanelLayoutNode(
|
||||
|
||||
@@ -1293,9 +1293,34 @@
|
||||
"panel",
|
||||
"tab",
|
||||
"modal",
|
||||
"settings"
|
||||
"settings",
|
||||
"vault-page",
|
||||
"terminal-sidebar"
|
||||
]
|
||||
},
|
||||
"VaultPagesFeature": {
|
||||
"const": "views.vault-pages"
|
||||
},
|
||||
"TerminalSidebarFeature": {
|
||||
"const": "views.terminal-sidebar"
|
||||
},
|
||||
"PluginViewFocusEvent": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"instanceId"
|
||||
],
|
||||
"properties": {
|
||||
"instanceId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginViewFocusRequest": {
|
||||
"$ref": "#/$defs/PluginViewFocusEvent"
|
||||
},
|
||||
"ViewContribution": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
const path = require("node:path");
|
||||
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
|
||||
const VAULT_PAGES_FEATURE = require("./generated/plugin-contract.schema.json").$defs.VaultPagesFeature.const;
|
||||
const TERMINAL_SIDEBAR_FEATURE = require("./generated/plugin-contract.schema.json").$defs.TerminalSidebarFeature.const;
|
||||
const INTERACTIVE_COMMAND_FEATURE = require("./generated/plugin-contract.schema.json").$defs.InteractiveCommandFeature.const;
|
||||
|
||||
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
|
||||
@@ -96,7 +98,7 @@ function createPluginHostService(options) {
|
||||
database,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
});
|
||||
const contributionIconService = new PluginContributionIconService({
|
||||
database,
|
||||
@@ -236,7 +238,7 @@ function createPluginHostService(options) {
|
||||
paths,
|
||||
netcattyVersion: options.app.getVersion(),
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])],
|
||||
runtimeDirectory,
|
||||
appRoot,
|
||||
rpcRegistry,
|
||||
@@ -364,6 +366,8 @@ function createPluginHostService(options) {
|
||||
packageStore,
|
||||
database,
|
||||
contributionService,
|
||||
isLocked: options.isAppLocked,
|
||||
subscribeLock: options.subscribeAppLock,
|
||||
})
|
||||
: null;
|
||||
const manager = new PluginManager({
|
||||
|
||||
@@ -5,6 +5,7 @@ const { randomUUID } = require("node:crypto");
|
||||
|
||||
const { createDefinitionValidator } = require("./contractValidator.cjs");
|
||||
const validateManagedPluginId = createDefinitionValidator("PluginId");
|
||||
const validateViewFocus = createDefinitionValidator("PluginViewFocusRequest");
|
||||
|
||||
const { raceWithAbort } = require("./rpcRouter.cjs");
|
||||
const {
|
||||
@@ -75,6 +76,7 @@ const CHANNELS = Object.freeze({
|
||||
importerProgress: "netcatty:plugins:importer-progress",
|
||||
openView: "netcatty:plugins:open-view",
|
||||
closeView: "netcatty:plugins:close-view",
|
||||
focusView: "netcatty:plugins:focus-view",
|
||||
setViewBounds: "netcatty:plugins:set-view-bounds",
|
||||
setViewVisibility: "netcatty:plugins:set-view-visibility",
|
||||
viewMessage: "netcatty:plugins:view-message",
|
||||
@@ -1605,6 +1607,12 @@ function registerPluginBridge(ipcMain, options) {
|
||||
await viewHost.close(payload?.instanceId, event.sender);
|
||||
return null;
|
||||
});
|
||||
handle(CHANNELS.focusView, async (_activeManager, payload, event) => {
|
||||
if (!viewHost) throw new Error("Plugin views are unavailable");
|
||||
if (!validateViewFocus(payload)) throw new TypeError("Plugin view focus request is invalid");
|
||||
viewHost.focus(payload.instanceId, event.sender);
|
||||
return null;
|
||||
});
|
||||
handle(CHANNELS.setViewBounds, async (_activeManager, payload, event) => {
|
||||
if (!viewHost) throw new Error("Plugin views are unavailable");
|
||||
viewHost.setBounds(payload?.instanceId, payload?.bounds, event.sender);
|
||||
|
||||
@@ -36,10 +36,19 @@ class PluginViewHost {
|
||||
this.contributionService = options.contributionService;
|
||||
this.preloadPath = options.preloadPath ?? path.join(__dirname, "runtime", "viewPreload.cjs");
|
||||
this.instances = new Map();
|
||||
this.opening = new Map();
|
||||
this.closed = false;
|
||||
this.isLocked = options.isLocked ?? (() => false);
|
||||
this.subscriptions = [];
|
||||
const unsubscribeLock = options.subscribeLock?.((state) => {
|
||||
if (!state.locked) return;
|
||||
for (const opening of this.opening.values()) opening.cancelled = true;
|
||||
for (const instanceId of [...this.instances.keys()]) void this.close(instanceId, undefined, "app-locked");
|
||||
});
|
||||
this.subscriptions.push({ dispose: () => unsubscribeLock?.() });
|
||||
this.byWebContentsId = new Map();
|
||||
this.closeListeners = new Set();
|
||||
this.environment = { locale: "en", theme: "system", reducedMotion: false, highContrast: false };
|
||||
this.subscriptions = [];
|
||||
this.#registerIpc();
|
||||
this.subscriptions.push(this.contributionService.onDidPostViewMessage((event) => {
|
||||
for (const instance of this.instances.values()) {
|
||||
@@ -49,10 +58,14 @@ class PluginViewHost {
|
||||
}
|
||||
}));
|
||||
this.subscriptions.push(this.contributionService.onDidChange?.((event) => {
|
||||
if (!["plugin-disabled", "runtime-stopped", "runtime-error", "runtime-quarantined"].includes(event.reason)
|
||||
|| !event.pluginId) return;
|
||||
for (const instance of [...this.instances.values()]) {
|
||||
if (instance.pluginId === event.pluginId) void this.close(instance.instanceId, undefined, event.reason);
|
||||
if (["plugin-disabled", "runtime-stopped", "runtime-error", "runtime-quarantined"].includes(event.reason)
|
||||
&& instance.pluginId === event.pluginId) {
|
||||
void this.close(instance.instanceId, undefined, event.reason);
|
||||
} else if (["vault-page", "terminal-sidebar"].includes(instance.location)) {
|
||||
try { this.contributionService.assertViewActivationCurrent(instance.activation, { context: instance.context }); }
|
||||
catch { void this.close(instance.instanceId, undefined, "contribution-unavailable"); }
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -115,10 +128,45 @@ class PluginViewHost {
|
||||
|
||||
async open(payload, sender) {
|
||||
if (!payload || typeof payload !== "object") throw new TypeError("Plugin view request is invalid");
|
||||
const owner = this.electron.BrowserWindow.fromWebContents(sender);
|
||||
if (this.closed || this.isLocked() || !owner || owner.isDestroyed()) throw new Error("Plugin view owner is unavailable");
|
||||
const instanceId = typeof payload.instanceId === "string" && payload.instanceId.length <= 128
|
||||
? payload.instanceId : randomUUID();
|
||||
if (this.instances.has(instanceId) || this.opening.has(instanceId)) throw new Error(`Plugin view already exists: ${instanceId}`);
|
||||
const opening = { owner, cancelled: false };
|
||||
const cancel = () => { opening.cancelled = true; };
|
||||
const rendererGone = () => { void this.close(instanceId, undefined, "owner-renderer-gone"); };
|
||||
const navigate = (details, _url, isInPlace, isMainFrame) => {
|
||||
if ((details?.isMainFrame ?? isMainFrame) !== false && (details?.isSameDocument ?? isInPlace) !== true) rendererGone();
|
||||
};
|
||||
const cleanupOwner = () => {
|
||||
sender.removeListener?.("render-process-gone", rendererGone);
|
||||
sender.removeListener?.("did-start-navigation", navigate);
|
||||
};
|
||||
this.opening.set(instanceId, opening);
|
||||
owner.once?.("closed", cancel);
|
||||
sender.on?.("render-process-gone", rendererGone);
|
||||
sender.on?.("did-start-navigation", navigate);
|
||||
try { return await this.#open({ ...payload, instanceId }, sender, opening); }
|
||||
finally {
|
||||
owner.removeListener?.("closed", cancel);
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (instance) instance.cleanupOwner = cleanupOwner;
|
||||
else cleanupOwner();
|
||||
if (this.opening.get(instanceId) === opening) this.opening.delete(instanceId);
|
||||
}
|
||||
}
|
||||
|
||||
#assertOpening(opening) {
|
||||
if (this.closed || this.isLocked() || opening.cancelled || opening.owner.isDestroyed()) throw new Error("Plugin view opening was cancelled");
|
||||
}
|
||||
|
||||
async #open(payload, sender, opening) {
|
||||
const { plugin, view, identity } = await this.contributionService.activateView(
|
||||
payload.viewId,
|
||||
{ context: payload.context },
|
||||
);
|
||||
this.#assertOpening(opening);
|
||||
const activation = Object.freeze({
|
||||
pluginId: plugin.id,
|
||||
pluginVersion: plugin.activeVersion,
|
||||
@@ -135,6 +183,7 @@ class PluginViewHost {
|
||||
: randomUUID();
|
||||
if (this.instances.has(instanceId)) throw new Error(`Plugin view already exists: ${instanceId}`);
|
||||
const packageRoot = await this.packageStore.preparePackageRoot(plugin);
|
||||
this.#assertOpening(opening);
|
||||
this.contributionService.assertViewActivationCurrent(activation, { context: payload.context });
|
||||
const partition = `netcatty-plugin-view-${randomUUID()}`;
|
||||
const pluginSession = this.electron.session.fromPartition(partition, { cache: false });
|
||||
@@ -183,12 +232,22 @@ class PluginViewHost {
|
||||
if (url !== registration.url) event.preventDefault();
|
||||
});
|
||||
contents.on("will-attach-webview", (event) => event.preventDefault());
|
||||
contents.once("render-process-gone", () => { void this.close(instanceId, undefined, "view-renderer-gone"); });
|
||||
if (["vault-page", "terminal-sidebar"].includes(view.location)) contents.on("before-input-event", (event, input) => {
|
||||
if (input.type === "keyDown" && input.key === "Escape" && !input.control && !input.meta && !input.alt && !input.shift) {
|
||||
event.preventDefault();
|
||||
void this.close(instanceId, undefined, "host-escape");
|
||||
}
|
||||
});
|
||||
const ownerClosed = () => { void this.close(instanceId, undefined, "owner-closed"); };
|
||||
const instance = {
|
||||
instanceId,
|
||||
pluginId: plugin.id,
|
||||
viewId: view.id,
|
||||
scopeId,
|
||||
location: view.location,
|
||||
activation,
|
||||
context: structuredClone(payload.context ?? {}),
|
||||
owner,
|
||||
ownerClosed,
|
||||
view: contentsView,
|
||||
@@ -197,8 +256,13 @@ class PluginViewHost {
|
||||
sessionRegistration,
|
||||
state: "opening",
|
||||
attached: false,
|
||||
visible: !["vault-page", "terminal-sidebar"].includes(view.location),
|
||||
};
|
||||
this.instances.set(instanceId, instance);
|
||||
contents.on("focus", () => {
|
||||
if (this.instances.get(instanceId) !== instance || instance.state !== "open") return;
|
||||
try { owner.webContents?.send("netcatty:plugins:view-focused", { instanceId }); } catch {}
|
||||
});
|
||||
this.byWebContentsId.set(contents.id, instance);
|
||||
owner.once?.("closed", ownerClosed);
|
||||
contents.once("destroyed", () => { void this.close(instanceId, undefined, "contents-destroyed"); });
|
||||
@@ -208,6 +272,10 @@ class PluginViewHost {
|
||||
|| owner.isDestroyed() || contents.isDestroyed()) {
|
||||
throw new Error("Plugin view ownership changed while opening");
|
||||
}
|
||||
this.#assertOpening(opening);
|
||||
// Embedded pages start hidden until the trusted renderer checks host
|
||||
// dialogs/lock/popovers. A late open must never cover a security surface.
|
||||
if (["vault-page", "terminal-sidebar"].includes(view.location)) contentsView.setVisible(false);
|
||||
owner.contentView.addChildView(contentsView);
|
||||
instance.attached = true;
|
||||
contentsView.setBounds(normalizeBounds(payload.bounds));
|
||||
@@ -232,7 +300,17 @@ class PluginViewHost {
|
||||
setVisible(instanceId, visible, sender) {
|
||||
if (typeof visible !== "boolean") throw new TypeError("Plugin view visibility must be a boolean");
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
if (visible && this.isLocked()) throw new Error("Plugin view owner is unavailable");
|
||||
const rising = visible && !instance.visible;
|
||||
instance.visible = visible;
|
||||
instance.view.setVisible(visible);
|
||||
if (rising && instance.location === "vault-page" && instance.owner.isFocused?.()) instance.contents.focus?.();
|
||||
}
|
||||
|
||||
focus(instanceId, sender) {
|
||||
const instance = this.#ownedInstance(instanceId, sender);
|
||||
if (!instance.visible || this.isLocked() || instance.owner.isFocused?.() !== true) return;
|
||||
instance.contents.focus?.();
|
||||
}
|
||||
|
||||
async postMessage(instanceId, message, sender) {
|
||||
@@ -250,11 +328,17 @@ class PluginViewHost {
|
||||
}
|
||||
|
||||
async close(instanceId, sender, reason = sender ? "renderer-requested" : "host-requested") {
|
||||
const opening = this.opening.get(instanceId);
|
||||
if (opening) {
|
||||
if (sender && this.electron.BrowserWindow.fromWebContents(sender) !== opening.owner) throw new Error("Untrusted plugin view owner");
|
||||
opening.cancelled = true;
|
||||
}
|
||||
const instance = this.instances.get(instanceId);
|
||||
if (!instance) return;
|
||||
if (sender) this.#ownedInstance(instanceId, sender);
|
||||
this.instances.delete(instanceId);
|
||||
this.byWebContentsId.delete(instance.contents.id);
|
||||
try { instance.cleanupOwner?.(); } catch {}
|
||||
try { instance.owner.removeListener?.("closed", instance.ownerClosed); } catch {}
|
||||
if (instance.attached) {
|
||||
try { instance.owner.contentView.removeChildView(instance.view); } catch {}
|
||||
@@ -279,6 +363,8 @@ class PluginViewHost {
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
this.closed = true;
|
||||
for (const opening of this.opening.values()) opening.cancelled = true;
|
||||
await Promise.all([...this.instances.keys()].map((instanceId) => this.close(instanceId)));
|
||||
for (const subscription of this.subscriptions.splice(0)) subscription?.dispose?.();
|
||||
this.closeListeners.clear();
|
||||
|
||||
@@ -25,6 +25,7 @@ function fixture(options = {}) {
|
||||
if (options.loadURL) await options.loadURL(this);
|
||||
}
|
||||
send(channel, payload) { this.sent.push([channel, payload]); }
|
||||
focus() { this.emit("focus"); }
|
||||
isDestroyed() { return this.destroyed; }
|
||||
close() { this.destroyed = true; this.emit("destroyed"); }
|
||||
}
|
||||
@@ -87,9 +88,10 @@ function fixture(options = {}) {
|
||||
onDidPostViewMessage(listener) { viewListener = listener; return { dispose() {} }; },
|
||||
onDidChange(listener) { changeListener = listener; return { dispose() {} }; },
|
||||
async activateView() {
|
||||
await options.activateView?.();
|
||||
return {
|
||||
plugin: { id: "com.example.view", activeVersion: "1.0.0" },
|
||||
view: { id: "com.example.view.panel", entry: "view.html", location: "aside" },
|
||||
view: { id: "com.example.view.panel", entry: "view.html", location: options.location ?? "aside" },
|
||||
identity: { runtimeId: "runtime-view-1", pluginVersion: "1.0.0" },
|
||||
};
|
||||
},
|
||||
@@ -121,6 +123,8 @@ function fixture(options = {}) {
|
||||
setViewState(pluginId, viewId, scopeId, value) { state.set(`${pluginId}:${viewId}:${scopeId}`, value); },
|
||||
},
|
||||
contributionService,
|
||||
isLocked: options.isLocked,
|
||||
subscribeLock: options.subscribeLock,
|
||||
preloadPath: "/runtime/viewPreload.cjs",
|
||||
});
|
||||
function createOwner() {
|
||||
@@ -131,7 +135,7 @@ function fixture(options = {}) {
|
||||
addChildView(view) { owner.children.push(view); },
|
||||
removeChildView(view) { owner.children = owner.children.filter((entry) => entry !== view); },
|
||||
};
|
||||
const sender = {};
|
||||
const sender = new EventEmitter();
|
||||
senderOwners.set(sender, owner);
|
||||
return { owner, sender };
|
||||
}
|
||||
@@ -341,3 +345,132 @@ test("view cleanup failures cannot suppress owner removal or the renderer close
|
||||
reason: "renderer-requested",
|
||||
}]);
|
||||
});
|
||||
|
||||
test("Vault pages start hidden and are withdrawn when their current contribution is no longer available", async () => {
|
||||
const value = fixture({ location: "vault-page" });
|
||||
const { owner, sender } = value.createOwner();
|
||||
const result = await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:vault-page" }, sender);
|
||||
assert.equal(owner.children.length, 1);
|
||||
assert.equal(value.views[0].visible, false, "host modal checks precede native visibility");
|
||||
value.host.setVisible(result.instanceId, true, sender);
|
||||
assert.equal(value.views[0].visible, true);
|
||||
value.setActivationCurrent(false);
|
||||
value.changeListener()({ reason: "context-key-changed" });
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.equal(value.views[0].webContents.destroyed, true);
|
||||
await value.host.shutdown();
|
||||
});
|
||||
|
||||
test("closing a reserved owner instance cancels activation before any native page is constructed", async () => {
|
||||
let release;
|
||||
const gate = new Promise((resolve) => { release = resolve; });
|
||||
const value = fixture({ location: "vault-page", activateView: () => gate });
|
||||
const { owner, sender } = value.createOwner();
|
||||
const other = value.createOwner();
|
||||
const opening = value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:vault-page", instanceId: "reserved-page" }, sender);
|
||||
await assert.rejects(value.host.close("reserved-page", other.sender), /Untrusted/);
|
||||
await value.host.close("reserved-page", sender);
|
||||
release();
|
||||
await assert.rejects(opening, /cancelled/);
|
||||
assert.equal(value.views.length, 0);
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.equal(value.host.opening.size, 0);
|
||||
await value.host.shutdown();
|
||||
});
|
||||
|
||||
test("app lock closes attached pages and cancels pending opens, with subscription cleanup", async () => {
|
||||
let listener, locked = false, disposed = 0;
|
||||
const value = fixture({ location: "vault-page", isLocked: () => locked,
|
||||
subscribeLock(fn) { listener = fn; return () => { disposed++; }; } });
|
||||
const { owner, sender } = value.createOwner();
|
||||
const result = await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:vault-page" }, sender);
|
||||
locked = true; listener({ locked: true });
|
||||
assert.equal(owner.children.length, 0);
|
||||
assert.equal(value.views[0].webContents.destroyed, true);
|
||||
await assert.rejects(value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:vault-page" }, sender), /unavailable/);
|
||||
assert.throws(() => value.host.setVisible(result.instanceId, true, sender));
|
||||
await value.host.shutdown();
|
||||
assert.equal(disposed, 1);
|
||||
});
|
||||
|
||||
test("browser Scratchpad saves, reopens and clears only its own view state through the real host IPC", async () => {
|
||||
const { readFile } = require("node:fs/promises");
|
||||
const path = require("node:path");
|
||||
const { JSDOM } = require("jsdom");
|
||||
const { VIEW_CHANNELS } = require("./pluginViewHost.cjs");
|
||||
const html = await readFile(path.join(__dirname, "../../examples/plugins/vault-scratchpad/page.html"), "utf8");
|
||||
const script = await readFile(path.join(__dirname, "../../examples/plugins/vault-scratchpad/page.js"), "utf8");
|
||||
const value = fixture({ location: "vault-page" });
|
||||
const { sender } = value.createOwner();
|
||||
async function open(scopeId) {
|
||||
const result = await value.host.open({ viewId: "com.example.view.panel", scopeId }, sender);
|
||||
const contents = value.host.instances.get(result.instanceId).contents;
|
||||
const dom = new JSDOM(html, { runScripts: "outside-only" });
|
||||
dom.window.netcattyView = {
|
||||
getState: async () => value.ipcHandlers.get(VIEW_CHANNELS.getState)({ sender: contents }),
|
||||
setState: async (state) => value.ipcHandlers.get(VIEW_CHANNELS.setState)({ sender: contents }, structuredClone(state)),
|
||||
};
|
||||
dom.window.eval(script);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
return { dom, document: dom.window.document, id: result.instanceId, contents };
|
||||
}
|
||||
const first = await open("window-1:vault-page");
|
||||
first.document.getElementById("draft").value = "synthetic private draft";
|
||||
first.document.getElementById("save").click();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.match(first.document.getElementById("status").textContent, /saved/);
|
||||
await value.host.close(first.id, sender); first.dom.window.close();
|
||||
const separate = await open("window-2:vault-page");
|
||||
assert.equal(separate.document.getElementById("draft").value, "", "a different host-owned state scope does not inherit this draft");
|
||||
const reopened = await open("window-1:vault-page");
|
||||
assert.equal(reopened.document.getElementById("draft").value, "synthetic private draft");
|
||||
reopened.document.getElementById("clear").click();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(reopened.document.getElementById("draft").value, "");
|
||||
const cleared = await open("window-1:vault-page");
|
||||
assert.equal(cleared.document.getElementById("draft").value, "");
|
||||
await value.host.shutdown();
|
||||
assert.throws(() => value.ipcHandlers.get(VIEW_CHANNELS.getState)({ sender: reopened.contents }), /Untrusted/);
|
||||
for (const panel of [separate, reopened, cleared]) panel.dom.window.close();
|
||||
});
|
||||
|
||||
test("host reload, host/view renderer exit, and host Escape close embedded pages", async () => {
|
||||
for (const action of ["reload", "host-exit", "view-exit", "escape"]) {
|
||||
const value = fixture({ location: "vault-page" });
|
||||
const { sender, owner } = value.createOwner();
|
||||
const opened = await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:vault-page" }, sender);
|
||||
const contents = value.views[0].webContents;
|
||||
sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: true });
|
||||
assert.equal(owner.children.length, 1);
|
||||
if (action === "reload") sender.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false });
|
||||
if (action === "host-exit") sender.emit("render-process-gone");
|
||||
if (action === "view-exit") contents.emit("render-process-gone");
|
||||
if (action === "escape") {
|
||||
let prevented = false;
|
||||
contents.emit("before-input-event", { preventDefault() { prevented = true; } }, { type: "keyDown", key: "Escape" });
|
||||
assert.equal(prevented, true);
|
||||
}
|
||||
assert.equal(owner.children.length, 0, action);
|
||||
assert.equal(value.host.instances.has(opened.instanceId), false, action);
|
||||
assert.equal(sender.listenerCount("render-process-gone"), 0, action);
|
||||
await value.host.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar native focus is explicit, owner-bound and cannot raise a hidden or locked page", async () => {
|
||||
let locked = false;
|
||||
const value = fixture({ location: "terminal-sidebar", isLocked: () => locked });
|
||||
const { owner, sender } = value.createOwner(); const other = value.createOwner();
|
||||
const events = [];
|
||||
sender.send = (channel, event) => events.push({ channel, event }); owner.webContents = sender; owner.isFocused = () => true;
|
||||
const { instanceId } = await value.host.open({ viewId: "com.example.view.panel", scopeId: "window-1:terminal-sidebar" }, sender);
|
||||
assert.equal(value.views[0].visible, false);
|
||||
value.host.focus(instanceId, sender); assert.deepEqual(events, []);
|
||||
value.host.setVisible(instanceId, true, sender); assert.deepEqual(events, [], "revealing multiple panes does not focus each one");
|
||||
assert.throws(() => value.host.focus(instanceId, other.sender), /another window/i);
|
||||
value.host.focus(instanceId, sender);
|
||||
assert.deepEqual(events, [{ channel: "netcatty:plugins:view-focused", event: { instanceId } }]);
|
||||
locked = true; value.host.focus(instanceId, sender); assert.equal(events.length, 1);
|
||||
locked = false; value.setActivationCurrent(false); value.changeListener()({ reason: "context-key-changed" });
|
||||
assert.equal(owner.children.length, 0); await value.host.shutdown();
|
||||
});
|
||||
@@ -270,6 +270,7 @@ function createPreloadApi(ctx) {
|
||||
},
|
||||
openPluginView: (payload) => ipcRenderer.invoke("netcatty:plugins:open-view", payload),
|
||||
closePluginView: (instanceId) => ipcRenderer.invoke("netcatty:plugins:close-view", { instanceId }),
|
||||
focusPluginView: (payload) => ipcRenderer.invoke("netcatty:plugins:focus-view", payload),
|
||||
setPluginViewBounds: (instanceId, bounds) => ipcRenderer.invoke("netcatty:plugins:set-view-bounds", { instanceId, bounds }),
|
||||
setPluginViewVisibility: (instanceId, visible) => ipcRenderer.invoke("netcatty:plugins:set-view-visibility", {
|
||||
instanceId,
|
||||
@@ -290,6 +291,11 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.on("netcatty:plugins:view-closed", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:view-closed", listener);
|
||||
},
|
||||
onPluginViewFocused: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on("netcatty:plugins:view-focused", listener);
|
||||
return () => ipcRenderer.removeListener("netcatty:plugins:view-focused", listener);
|
||||
},
|
||||
getPluginScopeCatalog: () => ipcRenderer.invoke("netcatty:plugins:get-scope-catalog"),
|
||||
setPluginScopeCatalog: (catalog) => ipcRenderer.invoke("netcatty:plugins:set-scope-catalog", catalog),
|
||||
onPluginScopeCatalogChanged: (callback) => {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Session Scratchpad
|
||||
|
||||
Fixture ID: `com.netcatty.session-scratchpad`, version `0.1.0`.
|
||||
Required permission: **views**, for this plugin's own pages only. Required
|
||||
feature: `views.terminal-sidebar`. No terminal/session read, clipboard, network,
|
||||
Vault, filesystem, credential or shell permission is requested.
|
||||
|
||||
```sh
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/session-scratchpad --out /tmp/session-scratchpad.ncpkg
|
||||
```
|
||||
|
||||
Import disabled through normal Settings, inspect and enable. Parent native QA
|
||||
must obtain a fixture-specific own-views approval; no previous plugin's grant
|
||||
applies. Use only an explicitly approved synthetic terminal target, without
|
||||
entering commands or connecting to real hosts for this page check.
|
||||
|
||||
In an open terminal, choose **Extension pages** in its toolbar, then **Session
|
||||
scratchpad**. This is a real existing side-panel pane. Save synthetic text,
|
||||
split beside a built-in tool, resize/move/magnify, return and reopen. The host
|
||||
keeps this page's state in a placement/session bucket; the page never receives
|
||||
the session ID, endpoint, cwd, title, history, selection or terminal output.
|
||||
This is plugin-owned text, not a Vault Note, and is not automatically synced.
|
||||
|
||||
For a separately approved second synthetic terminal/workspace focus target,
|
||||
verify that navigation closes the old native view and uses a separate draft
|
||||
bucket. Switch back to verify the saved first draft. Clear it to verify
|
||||
deletion. Close the pane, press Escape, disable/revoke, and verify cleanup.
|
||||
Settings/permission dialogs and overlapping menus must stay above native
|
||||
content. No new native target or persistent grant should be inferred from this
|
||||
README; parent coordinates the exact test and records source SHA/package hash.
|
||||
@@ -0,0 +1 @@
|
||||
export default { activate() {} };
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.session-scratchpad",
|
||||
"name": "session-scratchpad",
|
||||
"displayName": {
|
||||
"en": "Session Scratchpad",
|
||||
"zh-CN": "会话草稿板"
|
||||
},
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"netcatty": ">=0.0.0",
|
||||
"api": ">=0.1.0-internal.1 <0.2.0"
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"views.terminal-sidebar"
|
||||
]
|
||||
},
|
||||
"main": {
|
||||
"browser": "browser.js"
|
||||
},
|
||||
"activationEvents": [
|
||||
"onView:com.netcatty.session-scratchpad.page"
|
||||
],
|
||||
"permissions": {
|
||||
"required": [
|
||||
"views"
|
||||
]
|
||||
},
|
||||
"contributes": {
|
||||
"views": [
|
||||
{
|
||||
"id": "com.netcatty.session-scratchpad.page",
|
||||
"title": {
|
||||
"en": "Session scratchpad",
|
||||
"zh-CN": "会话草稿板"
|
||||
},
|
||||
"location": "terminal-sidebar",
|
||||
"entry": "page.html",
|
||||
"order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
body { margin: 0; padding: 24px; } main { max-width: 720px; margin: auto; }
|
||||
textarea { display: block; box-sizing: border-box; width: 100%; margin: 12px 0; padding: 10px; font: inherit; }
|
||||
button { padding: 8px 12px; margin: 0 8px 0 0; font: inherit; }
|
||||
#status { min-height: 1.5em; }
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Session Scratchpad</title><link rel="stylesheet" href="page.css"></head>
|
||||
<body><main><h1>Session scratchpad</h1><p>This page stores only text you type here, in this plugin's own view state. It cannot read Vault hosts, notes, credentials, sessions or the clipboard.</p>
|
||||
<label for="draft">Synthetic draft</label><textarea id="draft" maxlength="4096" rows="8" disabled></textarea>
|
||||
<button type="button" id="save" disabled>Save draft</button><button type="button" id="clear" disabled>Clear draft</button>
|
||||
<p id="status" role="status">Loading your plugin draft…</p></main><script src="page.js"></script></body></html>
|
||||
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
/* global document, window */
|
||||
const draft = document.getElementById("draft");
|
||||
const status = document.getElementById("status");
|
||||
let busy = false;
|
||||
async function save(clear) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const text = clear ? "" : draft.value;
|
||||
await window.netcattyView.setState({ version: 1, text });
|
||||
if (clear) draft.value = "";
|
||||
status.textContent = clear ? "Draft cleared." : "Draft saved in this plugin only.";
|
||||
} catch { status.textContent = "Could not save this draft."; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
document.getElementById("save").addEventListener("click", () => { void save(false); });
|
||||
document.getElementById("clear").addEventListener("click", () => { void save(true); });
|
||||
window.netcattyView.getState().then((state) => {
|
||||
if (state?.version === 1 && typeof state.text === "string" && state.text.length <= 4096) draft.value = state.text;
|
||||
status.textContent = "Ready. Use synthetic text for this example.";
|
||||
draft.disabled = false;
|
||||
document.getElementById("save").disabled = false;
|
||||
document.getElementById("clear").disabled = false;
|
||||
}).catch(() => { status.textContent = "Could not load this plugin draft."; });
|
||||
@@ -0,0 +1,27 @@
|
||||
# Vault Scratchpad
|
||||
|
||||
Browser-only example of an actual Vault extension page. It needs only `views`
|
||||
and the required `views.vault-pages` feature. No host/session/Vault data,
|
||||
clipboard, network, filesystem, credentials or shell permission is requested.
|
||||
Text explicitly typed here is stored in this plugin's own bounded view state;
|
||||
it is not a Netcatty Note and is not automatically cloud-synced.
|
||||
|
||||
```sh
|
||||
node packages/plugin-cli/dist/cli.js pack examples/plugins/vault-scratchpad --out /tmp/vault-scratchpad.ncpkg
|
||||
```
|
||||
|
||||
Import disabled through normal Settings, inspect and enable it. Under Vault's
|
||||
**Extensions** group choose **Scratchpad**. The sandbox must fill Vault's main
|
||||
content area, retaining the built-in sidebar and top tabs. Opening its detail
|
||||
entry must navigate to this same Vault page, without creating a top plugin tab.
|
||||
|
||||
For native QA, a separate explicit approval for this fixture's own `views`
|
||||
permission is required; earlier fixtures' grants do not cover this ID. With
|
||||
synthetic text, save, return to Hosts and reopen; verify the draft. Clear and
|
||||
reopen to verify deletion. Cancel the first permission prompt to verify that no
|
||||
page attaches. Switch away during opening, then disable/revoke and verify the
|
||||
page/entry disappear. Closing or withdrawing a page returns to Hosts. A host
|
||||
modal or overlapping menu must hide the native view while it is present.
|
||||
|
||||
This source example is not independent/native acceptance evidence. Parent QA
|
||||
records the precise tested commit and package hash separately.
|
||||
@@ -0,0 +1 @@
|
||||
export default { activate() {} };
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"manifestVersion": 1,
|
||||
"id": "com.netcatty.vault-scratchpad",
|
||||
"name": "vault-scratchpad",
|
||||
"displayName": { "en": "Vault Scratchpad", "zh-CN": "Vault 草稿板" },
|
||||
"version": "0.1.0",
|
||||
"publisher": "netcatty",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
|
||||
"features": { "required": ["views.vault-pages"] },
|
||||
"main": { "browser": "browser.js" },
|
||||
"activationEvents": ["onView:com.netcatty.vault-scratchpad.page"],
|
||||
"permissions": { "required": ["views"] },
|
||||
"contributes": {
|
||||
"views": [{
|
||||
"id": "com.netcatty.vault-scratchpad.page",
|
||||
"title": { "en": "Scratchpad", "zh-CN": "草稿板" },
|
||||
"location": "vault-page",
|
||||
"entry": "page.html",
|
||||
"order": 10
|
||||
}]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
:root { color-scheme: light dark; font-family: system-ui, sans-serif; }
|
||||
body { margin: 0; padding: 24px; } main { max-width: 720px; margin: auto; }
|
||||
textarea { display: block; box-sizing: border-box; width: 100%; margin: 12px 0; padding: 10px; font: inherit; }
|
||||
button { padding: 8px 12px; margin: 0 8px 0 0; font: inherit; }
|
||||
#status { min-height: 1.5em; }
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Vault Scratchpad</title><link rel="stylesheet" href="page.css"></head>
|
||||
<body><main><h1>Scratchpad</h1><p>This page stores only text you type here, in this plugin's own view state. It cannot read Vault hosts, notes, credentials, sessions or the clipboard.</p>
|
||||
<label for="draft">Synthetic draft</label><textarea id="draft" maxlength="4096" rows="8" disabled></textarea>
|
||||
<button type="button" id="save" disabled>Save draft</button><button type="button" id="clear" disabled>Clear draft</button>
|
||||
<p id="status" role="status">Loading your plugin draft…</p></main><script src="page.js"></script></body></html>
|
||||
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
/* global document, window */
|
||||
const draft = document.getElementById("draft");
|
||||
const status = document.getElementById("status");
|
||||
let busy = false;
|
||||
async function save(clear) {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const text = clear ? "" : draft.value;
|
||||
await window.netcattyView.setState({ version: 1, text });
|
||||
if (clear) draft.value = "";
|
||||
status.textContent = clear ? "Draft cleared." : "Draft saved in this plugin only.";
|
||||
} catch { status.textContent = "Could not save this draft."; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
document.getElementById("save").addEventListener("click", () => { void save(false); });
|
||||
document.getElementById("clear").addEventListener("click", () => { void save(true); });
|
||||
window.netcattyView.getState().then((state) => {
|
||||
if (state?.version === 1 && typeof state.text === "string" && state.text.length <= 4096) draft.value = state.text;
|
||||
status.textContent = "Ready. Use synthetic text for this example.";
|
||||
draft.disabled = false;
|
||||
document.getElementById("save").disabled = false;
|
||||
document.getElementById("clear").disabled = false;
|
||||
}).catch(() => { status.textContent = "Could not load this plugin draft."; });
|
||||
Vendored
+2
@@ -581,10 +581,12 @@ declare global {
|
||||
closePluginView?(instanceId: string): Promise<void>;
|
||||
setPluginViewBounds?(instanceId: string, bounds: { x: number; y: number; width: number; height: number }): Promise<void>;
|
||||
setPluginViewVisibility?(instanceId: string, visible: boolean): Promise<void>;
|
||||
focusPluginView?(payload: import('@netcatty/plugin-contract').PluginViewFocusRequest): Promise<void>;
|
||||
postPluginViewMessage?(instanceId: string, message: unknown): Promise<void>;
|
||||
onPluginContributionsChanged?(callback: (event: { reason: string; pluginId: string | null; revision: number }) => void): () => void;
|
||||
onPluginViewMessage?(callback: (event: { pluginId: string; viewId: string; message: unknown }) => void): () => void;
|
||||
onPluginViewClosed?(callback: (event: NetcattyPluginViewClosedEvent) => void): () => void;
|
||||
onPluginViewFocused?(callback: (event: import('@netcatty/plugin-contract').PluginViewFocusEvent) => void): () => void;
|
||||
getPluginScopeCatalog?(): Promise<NetcattyPluginScopeCatalog>;
|
||||
setPluginScopeCatalog?(catalog: NetcattyPluginScopeCatalog): Promise<void>;
|
||||
onPluginScopeCatalogChanged?(callback: (catalog: NetcattyPluginScopeCatalog) => void): () => void;
|
||||
|
||||
@@ -367,6 +367,12 @@ function validateSemantics(manifest: PluginManifest): string[] {
|
||||
}
|
||||
|
||||
const requiredFeatures = new Set(manifest.features?.required ?? []);
|
||||
if (manifest.contributes?.views?.some((view) => view.location === 'terminal-sidebar')
|
||||
&& !requiredFeatures.has('views.terminal-sidebar')) errors.push('Terminal sidebar pages require views.terminal-sidebar');
|
||||
if (manifest.contributes?.views?.some((view) => view.location === 'vault-page')
|
||||
&& !requiredFeatures.has('views.vault-pages')) {
|
||||
errors.push('Vault pages require views.vault-pages');
|
||||
}
|
||||
if (manifest.contributes?.commands?.some((command) => command.interactive)
|
||||
&& !requiredFeatures.has('commands.interactive')) errors.push('Interactive commands require commands.interactive');
|
||||
if (manifest.icon) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { validateManifestValue } from './manifest.js';
|
||||
import { checkPluginCompatibility } from './compatibility.js';
|
||||
const manifest = JSON.parse(await readFile(new URL('../../../examples/plugins/session-scratchpad/netcatty.plugin.json', import.meta.url), 'utf8'));
|
||||
test('terminal sidebar pages require views and their exact feature without session data permissions', () => {
|
||||
assert.equal(validateManifestValue(manifest).valid, true);
|
||||
assert.equal(validateManifestValue({ ...manifest, features: {} }).valid, false);
|
||||
assert.equal(validateManifestValue({ ...manifest, permissions: {} }).valid, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['views.vault-pages'] }).compatible, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['views.terminal-sidebar'] }).compatible, true);
|
||||
assert.deepEqual(manifest.permissions.required, ['views']);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { validateManifestValue } from './manifest.js';
|
||||
import { checkPluginCompatibility } from './compatibility.js';
|
||||
|
||||
const manifest = JSON.parse(await readFile(new URL('../../../examples/plugins/vault-scratchpad/netcatty.plugin.json', import.meta.url), 'utf8'));
|
||||
test('Vault pages require exact feature negotiation and the existing views permission', () => {
|
||||
assert.equal(validateManifestValue(manifest).valid, true);
|
||||
assert.equal(validateManifestValue({ ...manifest, features: {} }).valid, false);
|
||||
assert.equal(validateManifestValue({ ...manifest, permissions: {} }).valid, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: [] }).compatible, false);
|
||||
assert.equal(checkPluginCompatibility(manifest, { netcattyVersion: '1.0.0', features: ['views.vault-pages'] }).compatible, true);
|
||||
const legacy = { ...manifest, features: {}, contributes: { views: [{ ...manifest.contributes.views[0], location: 'tab' }] } };
|
||||
assert.equal(validateManifestValue(legacy).valid, true, 'legacy tab packages keep their contract');
|
||||
});
|
||||
@@ -1005,8 +1005,21 @@
|
||||
},
|
||||
"ViewLocation": {
|
||||
"type": "string",
|
||||
"enum": ["aside", "panel", "tab", "modal", "settings"]
|
||||
"enum": ["aside", "panel", "tab", "modal", "settings", "vault-page", "terminal-sidebar"]
|
||||
},
|
||||
"VaultPagesFeature": {
|
||||
"const": "views.vault-pages"
|
||||
},
|
||||
"TerminalSidebarFeature": {
|
||||
"const": "views.terminal-sidebar"
|
||||
},
|
||||
"PluginViewFocusEvent": {
|
||||
"type": "object",
|
||||
"required": ["instanceId"],
|
||||
"properties": { "instanceId": { "type": "string", "minLength": 1, "maxLength": 128 } },
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PluginViewFocusRequest": { "$ref": "#/$defs/PluginViewFocusEvent" },
|
||||
"ViewContribution": {
|
||||
"type": "object",
|
||||
"required": ["id", "title", "location", "entry"],
|
||||
|
||||
@@ -587,6 +587,12 @@ export type PluginNetworkResponse = {
|
||||
|
||||
export type PluginPermission = "storage" | "runtime.advanced" | "settings.read" | "settings.write" | "commands" | "menus" | "views" | "ui.interact" | "clipboard.read" | "clipboard.write" | "terminal.metadata" | "terminal.output" | "terminal.input" | "terminal.decorate" | "terminal.complete" | "terminal.intercept.input" | "terminal.intercept.output" | "vault.metadata" | "vault.write" | "vault.credentials" | "sftp.read" | "sftp.write" | "network" | "filesystem.read" | "filesystem.write" | "secrets" | "companion.execute" | "provider.terminal" | "provider.connection" | "provider.authentication" | "provider.sync" | "provider.importer";
|
||||
|
||||
export type PluginViewFocusEvent = {
|
||||
instanceId: string;
|
||||
};
|
||||
|
||||
export type PluginViewFocusRequest = PluginViewFocusEvent;
|
||||
|
||||
export type PluginWireErrorCode = -32001 | -32002 | -32003 | -32004 | -32005 | -32006 | -32007 | -32008 | -32009 | -32010 | -32011 | -32012 | -32013 | -32014 | -32015 | -32016;
|
||||
|
||||
export type ProgressBegin = {
|
||||
@@ -1035,11 +1041,15 @@ export type TerminalSessionSnapshot = {
|
||||
alternateScreen?: boolean;
|
||||
};
|
||||
|
||||
export type TerminalSidebarFeature = "views.terminal-sidebar";
|
||||
|
||||
export type ThemeIcon = {
|
||||
kind: "theme";
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type VaultPagesFeature = "views.vault-pages";
|
||||
|
||||
export type ViewContribution = {
|
||||
id: ContributionId;
|
||||
title: LocalizedText;
|
||||
@@ -1051,6 +1061,6 @@ export type ViewContribution = {
|
||||
retainContextWhenHidden?: boolean;
|
||||
};
|
||||
|
||||
export type ViewLocation = "aside" | "panel" | "tab" | "modal" | "settings";
|
||||
export type ViewLocation = "aside" | "panel" | "tab" | "modal" | "settings" | "vault-page" | "terminal-sidebar";
|
||||
|
||||
export type WireIntegerLimits = {"maxSafeInteger":9007199254740991};
|
||||
@@ -55,4 +55,6 @@ export const PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE = "network.credential-lease
|
||||
|
||||
export const PLUGIN_HOST_INTERACTION_FEATURE = "host.interactions" as const satisfies import("./generated/plugin-contract.js").HostInteractionFeature;
|
||||
|
||||
export const PLUGIN_VAULT_PAGES_FEATURE = "views.vault-pages" as const satisfies import("./generated/plugin-contract.js").VaultPagesFeature;
|
||||
export const PLUGIN_TERMINAL_SIDEBAR_FEATURE = "views.terminal-sidebar" as const satisfies import("./generated/plugin-contract.js").TerminalSidebarFeature;
|
||||
export const PLUGIN_INTERACTIVE_COMMAND_FEATURE = "commands.interactive" as const satisfies import("./generated/plugin-contract.js").InteractiveCommandFeature;
|
||||
@@ -930,4 +930,6 @@ export function throwIfCancellationRequested(token: CancellationToken): void {
|
||||
export { PLUGIN_NETWORK_CREDENTIAL_LEASE_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_HOST_INTERACTION_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_VAULT_PAGES_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_TERMINAL_SIDEBAR_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_INTERACTIVE_COMMAND_FEATURE } from "@netcatty/plugin-contract";
|
||||
Reference in new issue
Block a user