Compare commits

..
Author SHA1 Message Date
陈大猫 f1410f8bb1 feat(plugins): validate installed stream example and integrate final source isolation 2026-10-27 01:19:32 +08:00
陈大猫 c4433d4961 feat(plugins): integrate source-bound byte streams and additive AI contract 2026-10-26 23:04:54 +08:00
Codex 4213d8e672 fix(plugins): distinguish third-party catalog status copy 2026-10-26 14:48:46 +00:00
陈奇 264f1d983c feat(plugin-ai): define registered service and assistant runtime adapters 2026-10-26 14:47:58 +00:00
陈奇 35e3f9f99f Merge branch 'feat/plugin-ai-context-core' into feat/plugin-ai-context 2026-10-26 14:47:58 +00:00
陈奇 4882e0501c fix(plugin-ai): validate context references before publishing turns 2026-10-26 14:46:37 +00:00
陈奇 c3a5f810ce fix(plugin-ai): validate context references before publishing turns 2026-10-26 14:46:30 +00:00
Codex 59721f9717 fix(plugins): pin sync owners across source lifecycle awaits 2026-10-26 14:37:25 +00:00
陈大猫 dd41f9d8d0 feat(plugins): prepare bounded byte transport and database fixture 2026-10-26 22:28:28 +08:00
陈奇 6392ac9532 feat(plugin-ai): reuse host model transport and add assistant panel fixture 2026-10-26 14:25:34 +00:00
陈奇 afe831bfc5 feat(plugin-ai): add source-bound model and selected context brokers 2026-10-26 14:25:18 +00:00
Codex ab8764a317 Merge reviewed theme security color aliases into source integration 2026-10-26 14:06:02 +00:00
Codex 7e72ae65ad test(plugins): assert catalog operations bind official source 2026-10-26 14:02:00 +00:00
Codex f2e878858f test(plugins): drain dialog autofocus cleanup before restoring DOM globals 2026-10-26 13:49:24 +00:00
Codex 2ff86c2217 fix(plugins): restore opener focus after source and install dialogs 2026-10-26 13:42:31 +00:00
Codex 5e6278cbc7 fix(plugins): distinguish installed packages from mirrored source entries 2026-10-26 13:34:59 +00:00
Codex 83952aac0d fix(plugins): resolve source-scoped credential owners for provider recovery 2026-10-26 13:31:36 +00:00
Codex 7ec6473112 fix(plugins): retain local provenance through source schema upgrades 2026-10-26 13:28:34 +00:00
Codex 17e6834f74 test(plugins): include source binding in catalog interaction fixtures 2026-10-26 13:19:46 +00:00
Codex 9ed8bf76dd feat(plugins): bind third-party markets to isolated source identities 2026-10-26 13:15:52 +00:00
陈奇 91b8927c56 fix(plugin-themes): pin utility color aliases in security surfaces 2026-10-26 13:07:03 +00:00
Codex 1e5e77fb9c merge: integrate plugin static distribution and capability theme stack 2026-10-26 12:34:54 +00:00
陈奇 f868221b5f fix(plugin-themes): validate text across shared neutral surfaces 2026-10-26 12:07:46 +00:00
陈奇 fd45463d98 test(transfers): avoid assuming concurrent file-preflight order 2026-10-26 12:07:46 +00:00
陈奇 fdeeaf74bb feat(plugins): add validated data-only host color themes 2026-10-26 11:46:08 +00:00
陈奇 ffabb9e9c7 Merge terminal sidebar navigation fix and projection regression 2026-10-26 11:17:24 +00:00
陈奇 733f44cfdc test(plugin-sidebar): cover production bridge callback projection 2026-10-26 11:10:11 +00:00
陈奇 a8adeb86fe fix(plugin-sidebar): forward production terminal navigation actions 2026-10-26 11:06:50 +00:00
陈奇 0c097a1260 docs(plugins): record bounded host workflow native acceptance 2026-10-26 10:29:12 +00:00
陈奇 5c9286775e feat(plugins): host extension pages in terminal split panes 2026-10-26 10:27:00 +00:00
陈奇 618257e593 Merge branch 'integration/plugin-capabilities-ui' into feat/plugin-vault-pages 2026-10-26 09:59:53 +00:00
陈奇 cde2be236e Merge branch 'feat/plugin-host-interactions' into integration/plugin-capabilities-ui 2026-10-26 09:59:43 +00:00
陈奇 342941b618 Merge branch 'integration/plugin-capabilities-ui' into feat/plugin-vault-pages
# Conflicts:
#	electron/plugins/hostService.cjs
#	packages/plugin-cli/src/manifest.ts
#	packages/plugin-contract/src/index.ts
#	packages/plugin-sdk/src/index.ts
2026-10-26 09:59:42 +00:00
陈奇 8e4dd0c052 fix(plugins): pin every command to its activation runtime 2026-10-26 09:59:34 +00:00
陈奇 3a96cd2537 Merge branch 'feat/plugin-host-interactions' into integration/plugin-capabilities-ui 2026-10-26 09:56:42 +00:00
陈奇 0a692dfc72 fix(plugins): bound interactive commands and cancel orphan host prompts 2026-10-26 09:53:25 +00:00
陈奇 1d369bf322 fix(plugins): ignore withdrawn page layout failures 2026-10-26 09:45:04 +00:00
陈奇 0d77a2e56c feat(plugins): mount sandbox extension pages inside Vault 2026-10-26 09:32:00 +00:00
Codex d0e2c905cb feat(plugins): bind static reads to versioned snapshot sidecar 2026-10-26 09:13:45 +00:00
陈奇 5f06fd3cec Merge branch 'feat/plugin-host-interactions' into integration/plugin-capabilities-ui 2026-10-26 09:08:50 +00:00
陈奇 d4ca123999 fix(plugins): preserve input fidelity and native permission focus handoff 2026-10-26 09:06:53 +00:00
Codex c11cde9bb2 feat(plugins): use official static distribution for marketplace reads 2026-10-26 08:57:23 +00:00
陈奇 3ac4772087 chore(plugins): integrate reviewed manager polish with capability layers 2026-10-26 08:54:08 +00:00
陈奇 71d455d5de feat(plugins): add host text dialogs and explicit clipboard sharing 2026-10-26 08:05:02 +00:00
Codex 2838f90b03 refactor(plugins): unify information and notice entry 2026-10-26 07:54:33 +00:00
陈奇 ff3734d161 fix(plugins): refresh actual state after failed grant revocation 2026-10-26 07:39:37 +00:00
Codex 494470810a refactor(plugins): compact notices and align card headings 2026-10-26 07:26:38 +00:00
陈奇 185eac3849 fix(plugins): finish revocation cleanup and identify inactive saved grants 2026-10-26 07:18:21 +00:00
Codex bf91894ab5 fix(plugins): keep replacement impact beside install action 2026-10-26 07:17:06 +00:00
Codex b39f7a3244 feat(plugins): simplify manager guidance and use full-page details 2026-10-26 07:05:38 +00:00
陈奇 9af123e5d7 feat(plugins): inspect and revoke grants and align network lease contracts 2026-10-26 07:03:14 +00:00
Codex e5bd294cdd fix(plugins): validate and prepare registry snapshots before publication 2026-10-26 06:32:35 +00:00
Codex b48567d448 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client
# Conflicts:
#	application/i18n/locales/en/core.ts
#	application/i18n/locales/es/core.ts
#	application/i18n/locales/ru/core.ts
#	application/i18n/locales/zh-CN/core.ts
#	application/i18n/locales/zh-TW/core.ts
2026-10-26 06:27:10 +00:00
Codex 933bc93fc3 feat(plugins): enrich plugin cards with author and descriptions 2026-10-26 06:26:36 +00:00
Codex 45ed193579 feat(plugins): expose manual community catalog refresh 2026-10-26 06:19:26 +00:00
Codex f471a17a75 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client 2026-10-26 06:17:13 +00:00
Codex b9293a7bb5 fix(plugins): keep sparse installed grids compact 2026-10-26 06:16:55 +00:00
Codex 257660bcc1 fix(plugins): keep normalized README URLs pinned to reviewed commits 2026-10-26 06:15:49 +00:00
Codex eaf8c9a385 test(plugins): include registry source in catalog fixture 2026-10-26 06:10:18 +00:00
Codex ff42d0c1b9 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client 2026-10-26 06:09:32 +00:00
Codex 0adb7b822e feat(plugins): show installed plugins in a compact grid 2026-10-26 06:09:20 +00:00
Codex 311765eaa1 feat(plugins): read validated official registry snapshots 2026-10-26 06:06:26 +00:00
Codex d2a8398b7a fix(plugins): parent permission prompts to focused settings window 2026-10-26 06:01:13 +00:00
Codex 69307cee16 fix(plugins): validate settings fixture activation and polish manager controls 2026-10-26 05:49:24 +00:00
Codex 4445c292ea fix(plugins): bound icon archive reads and reject prototype glyph lookups 2026-10-26 05:45:53 +00:00
Codex 6fad272afe style(plugins): use theme-accent status pills for enable controls 2026-10-26 05:35:45 +00:00
Codex a7f8480da1 test(plugins): check packaging against the current workspace revision 2026-10-26 05:35:45 +00:00
Codex e7e57f2a52 fix(plugins): align root workspace dependencies with API revision 2026-10-26 05:31:23 +00:00
Codex ab2f95837e test(plugins): add static settings view handoff fixture 2026-10-26 05:29:34 +00:00
Codex f3d04a1912 chore(plugins): pin reviewed Starter brand preview to source snapshot 2026-10-26 05:27:55 +00:00
Codex f4a6b205ad feat(plugins): add versioned offline brand icons and author guidance 2026-10-26 05:26:25 +00:00
Codex 614b7c997a fix(plugins): release manager modal before opening settings views 2026-10-26 05:21:00 +00:00
Codex aaef0d2ee1 feat(plugins): open management in a dedicated dialog 2026-10-26 05:13:37 +00:00
Codex 5128b51702 fix(plugins): soften selection colors and label historical errors 2026-10-26 04:13:20 +00:00
Codex f5c951f9a1 fix(plugins): restore narrow community browsing scroll position 2026-10-26 04:04:49 +00:00
Codex 8de33d9b5a feat(plugins): integrate responsive community list and detail layout 2026-10-26 04:02:33 +00:00
Codex c727cca643 fix(plugins): use managed catalog temp storage and lint browser example 2026-10-26 03:49:32 +00:00
Codex ba4fab5679 test(plugins): account for Starter runtime fixture build 2026-10-26 03:42:27 +00:00
Codex 27e5a10bfc chore(plugins): pin Starter panel example source 2026-10-26 03:41:29 +00:00
Codex 3efabf2b0f fix(plugins): expose a visible Starter panel and bundle shared browser helpers 2026-10-26 03:41:21 +00:00
Codex 74a2f99b08 fix(plugins): preserve sync sidecar coordinates on Node 22 2026-10-26 03:35:17 +00:00
Codex 80525d9b2d test(plugins): enforce shared policy and permission UI coverage in CI 2026-10-26 03:27:55 +00:00
Codex 741ea4e9c7 feat(plugins): pin the minimal starter and updated example documentation 2026-10-26 03:25:56 +00:00
Codex 4336f8fd53 feat(plugins): open browser-only settings with AI-style subpages and safe starter 2026-10-26 03:25:40 +00:00
Codex aa8989c515 Merge branch 'feat/plugin-settings-manager' into feat/plugin-curated-catalog 2026-10-26 03:15:25 +00:00
Codex b6b45fc11a fix(plugins): complete manager locale parity and local CLI assertions 2026-10-26 03:15:24 +00:00
Codex d5fd56ecdf feat(plugins): add curated browser-only catalog and safe README details 2026-10-26 03:10:48 +00:00
Codex d601d3d238 feat(plugins): add installed package management to settings 2026-10-26 02:59:36 +00:00
Codex e20f2a9986 fix(plugins): disable incompatible plugins before lazy contributions 2026-10-26 02:54:41 +00:00
360 changed files with 19154 additions and 2090 deletions

No files matched your search

+6
View File
@@ -49,6 +49,12 @@ jobs:
- name: Verify Codex App Server schema
run: npm run check:codex-app-server-schema
- name: Verify plugin contract and runtime boundaries
run: |
npm run check:plugin-contract
npm run test:plugin-runtime
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test
run: npm test
+6 -3
View File
@@ -106,10 +106,13 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
## Plugin host runtime (internal preview)
## Plugin host runtime
The phase-2 plugin host lives under `electron/plugins/` and is disabled unless
`NETCATTY_PLUGIN_DEV=1` is present at application launch. Public wire and package
The plugin host under `electron/plugins/` exposes browser-only packages in normal
Settings. `NETCATTY_PLUGIN_DEV=1` admits advanced development packages, without
granting their permissions. Admission and default placement share
`domain/pluginRuntimePolicy.cjs`; permission explanations shared with native
prompts live in `domain/pluginPermissionDescriptions.cjs`. Public wire and package
types still come only from `packages/plugin-contract/schema/`; do not add a
second private RPC shape when extending the host.
+2
View File
@@ -12,6 +12,7 @@ import { ToastProvider } from './components/ui/toast';
import { TooltipProvider } from './components/ui/tooltip';
import { ScriptAutomationRoot } from './components/scripts/ScriptAutomationRoot';
import { ExternalMcpApprovalsHost } from './components/ai/ExternalMcpApprovalsHost';
import { PluginHostInteractionHost } from './components/plugins/PluginHostInteractionHost';
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
@@ -86,6 +87,7 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
<ScriptAutomationRoot />
<ExternalMcpApprovalsHost />
<PluginAuthenticationHost />
<PluginHostInteractionHost />
<MultilinePasteConfirmHost />
<VaultPublisher>
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
+2 -1
View File
@@ -167,7 +167,8 @@ function AppPluginKeybindingThemeHost({
sessions: any[];
workspaces: any[];
}) {
const appThemeStyle = useAppThemeStyle();
// View environment is global in the current host: publish committed colors only.
const appThemeStyle = useAppThemeStyle(false);
const { resolvedTheme, uiLanguage } = useSettingsChromeStore();
const pluginThemeTokens = useMemo(
() => selectPluginThemeTokens(appThemeStyle as Record<string, unknown>),
+6 -2
View File
@@ -1,3 +1,4 @@
import { usePluginThemeRevision } from '../state/usePluginThemeRuntime';
import type React from 'react';
import { useMemo } from 'react';
@@ -12,17 +13,20 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
* settingsChromeStore so accent drags only re-render the leaf that applies the
* vars, never the App shell.
*/
export function useAppThemeStyle(): React.CSSProperties {
export function useAppThemeStyle(includePreview = true): React.CSSProperties {
const pluginThemeRevision = usePluginThemeRevision();
const { accentMode, customAccent } = useAppearanceChromeStore();
const { resolvedTheme, darkUiThemeId, lightUiThemeId } = useSettingsChromeStore();
return useMemo(() => {
void pluginThemeRevision; // getUiThemeById reads this external registry revision.
const tokens = getUiThemeById(
resolvedTheme,
resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId,
includePreview,
).tokens;
return {
...buildAppThemeCssVars(tokens, accentMode, customAccent),
colorScheme: resolvedTheme,
} as React.CSSProperties;
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme]);
}, [accentMode, customAccent, darkUiThemeId, lightUiThemeId, resolvedTheme, pluginThemeRevision, includePreview]);
}
+117 -2
View File
@@ -1235,8 +1235,123 @@ Highlight the focused split pane:
'deepLink.jms.invalid': 'Invalid JumpServer link',
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
'settings.tab.plugins': 'Plugins',
'settings.plugins.title': 'Plugin settings',
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
"settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
"settings.plugins.manager.enabled": "Enabled",
"settings.plugins.manager.disabled": "Disabled",
"settings.plugins.manager.enable": "Enable",
"settings.plugins.manager.disable": "Disable",
"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",
"plugins.interaction.filter": "Filter choices",
"plugins.interaction.preview": "Preview clipboard",
"plugins.interaction.previewNote": "The clipboard is read only after you choose Preview. You can then inspect the text and separately confirm sharing it.",
"plugins.interaction.previewAction": "Preview clipboard",
"plugins.interaction.share": "Share with plugin",
"plugins.interaction.copy": "Copy to clipboard",
"plugins.interaction.failed": "This request expired or could not be completed.",
"settings.plugins.permissions.current": "Matches the current activation",
"settings.plugins.permissions.inactive": "Inactive for the current activation",
"settings.plugins.permissions.title": "Saved grants",
"settings.plugins.permissions.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading grants...",
"settings.plugins.permissions.empty": "No saved grants. One-use decisions are not retained.",
"settings.plugins.permissions.always": "Until revoked",
"settings.plugins.permissions.application": "Until Netcatty exits",
"settings.plugins.permissions.session": "Until the session closes",
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
"settings.plugins.themes.title": "Plugin themes",
"settings.plugins.themes.description": "Preview changes only this window. Apply saves this device’s selection. Personal accent, fonts and terminal overrides stay in control.",
"settings.plugins.themes.empty": "No enabled plugin themes for this mode.",
"settings.plugins.themes.choose": "Choose a theme",
"settings.plugins.themes.unavailable": "Saved theme unavailable; using the host default",
"settings.plugins.themes.applied": "Applied",
"settings.plugins.themes.preview": "Preview",
"settings.plugins.themes.apply": "Apply",
"settings.plugins.themes.cancel": "Cancel preview",
"settings.plugins.themes.restore": "Restore default",
"settings.plugins.themes.invalid": "Some theme palettes were rejected or unavailable.",
"settings.plugins.themes.failed": "Could not apply theme.",
"settings.plugins.manager.dataTheme": "Theme pack: validated colors, no executable runtime.",
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"settings.plugins.sources.offline": "Source unavailable. If a previously validated catalog exists for this source, it remains available for browsing only. Installation is unavailable until refresh succeeds.",
"settings.plugins.sources.note": "This catalog is provided by the selected third party, not curated by Netcatty. Review the source domain and permissions before installing. Updates are manual.",
"settings.plugins.sources.sourceOnly": "Source-only entry from this provider. No downloadable package is available; review the author’s build and import instructions.",
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
"settings.plugins.status": "Notices: {count}",
"settings.plugins.info": "Plugin information",
"settings.plugins.manager.noVaultShort": "Secret storage unavailable",
"settings.plugins.market.offlineShort": "Offline catalog · installation unavailable",
"settings.plugins.market.readmeInfo": "About this README",
"settings.plugins.market.installed": "Installed",
"settings.plugins.market.sourceBadge": "Source example",
"settings.plugins.market.title": "Marketplace",
"settings.plugins.market.note": "Curated open-source plugins. Review is not a security certification. Updates are manual.",
"settings.plugins.market.back": "All plugins",
"settings.plugins.market.install": "Install this version (disabled)",
"settings.plugins.market.installNote": "This replaces and stops the installed version. Review its permissions in Installed plugins before enabling.",
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
"settings.plugins.manager.sensitive": "Sensitive access: review the data and resource scope before granting.",
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
"settings.plugins.market.retry": "Retry",
"settings.plugins.market.empty": "No catalog entries are available. You can still import a local browser plugin.",
"settings.plugins.market.emptyReadme": "This README is empty.",
"settings.plugins.market.search": "Search name, author or description",
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
"settings.plugins.market.manifest": "Compatibility and permissions",
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
"settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.',
'settings.plugins.scopeContext': 'Open this setting in its {scope} context.',
-3
View File
@@ -975,9 +975,6 @@ export const enVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
'hostDetails.backspaceBehavior': 'Backspace Behavior',
'hostDetails.backspaceBehavior.default': 'Default',
'hostDetails.jumpHosts': 'Proxy via Hosts',
+117 -2
View File
@@ -1235,8 +1235,123 @@ Resalta el panel dividido enfocado:
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
'settings.tab.plugins': 'Plugins',
'settings.plugins.title': 'Configuración de plugins',
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
"settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
"settings.plugins.manager.enabled": "Activado",
"settings.plugins.manager.disabled": "Desactivado",
"settings.plugins.manager.enable": "Activar",
"settings.plugins.manager.disable": "Desactivar",
"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",
"plugins.interaction.filter": "Filtrar opciones",
"plugins.interaction.preview": "Vista previa del portapapeles",
"plugins.interaction.previewNote": "El portapapeles se lee al elegir Vista previa. Revise el texto y confirme por separado que desea compartirlo.",
"plugins.interaction.previewAction": "Vista previa del portapapeles",
"plugins.interaction.share": "Compartir con el complemento",
"plugins.interaction.copy": "Copiar al portapapeles",
"plugins.interaction.failed": "La solicitud caducó o no pudo completarse.",
"settings.plugins.permissions.current": "Coincide con la ejecución actual",
"settings.plugins.permissions.inactive": "No activa para la ejecución actual",
"settings.plugins.permissions.title": "Autorizaciones guardadas",
"settings.plugins.permissions.refresh": "Actualizar",
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
"settings.plugins.permissions.empty": "No hay autorizaciones guardadas. Las decisiones de un solo uso no se conservan.",
"settings.plugins.permissions.always": "Hasta su revocación",
"settings.plugins.permissions.application": "Hasta cerrar Netcatty",
"settings.plugins.permissions.session": "Hasta cerrar la sesión",
"settings.plugins.permissions.audit": "Eventos de seguridad recientes (hasta 100)",
"settings.plugins.permissions.revoke": "Desactivar y revocar todas las autorizaciones",
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
"settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
"settings.plugins.themes.title": "Temas de plugins",
"settings.plugins.themes.description": "La vista previa solo cambia esta ventana. Aplicar guarda la selección en este dispositivo. Se conservan los ajustes personales.",
"settings.plugins.themes.empty": "No hay temas habilitados para este modo.",
"settings.plugins.themes.choose": "Elegir tema",
"settings.plugins.themes.unavailable": "Tema guardado no disponible; se usa el predeterminado",
"settings.plugins.themes.applied": "Aplicado",
"settings.plugins.themes.preview": "Vista previa",
"settings.plugins.themes.apply": "Aplicar",
"settings.plugins.themes.cancel": "Cancelar vista previa",
"settings.plugins.themes.restore": "Restaurar predeterminado",
"settings.plugins.themes.invalid": "Algunas paletas no están disponibles o no son válidas.",
"settings.plugins.themes.failed": "No se pudo aplicar el tema.",
"settings.plugins.manager.dataTheme": "Paquete de temas: colores validados, sin ejecución de código.",
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"settings.plugins.sources.offline": "Fuente no disponible. Si existe un catálogo validado en caché de esta fuente, solo se permite consultarlo. La instalación no estará disponible hasta que se actualice correctamente.",
"settings.plugins.sources.note": "Este catálogo lo proporciona el tercero seleccionado; Netcatty no lo selecciona ni revisa. Comprueba el dominio y los permisos antes de instalar. Las actualizaciones son manuales.",
"settings.plugins.sources.sourceOnly": "Entrada de código fuente de este proveedor. No hay un paquete descargable; consulta las instrucciones de compilación e importación del autor.",
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
"settings.plugins.status": "{count} avisos",
"settings.plugins.info": "Información de plugins",
"settings.plugins.manager.noVaultShort": "Almacenamiento de secretos no disponible",
"settings.plugins.market.offlineShort": "Catálogo sin conexión · instalación no disponible",
"settings.plugins.market.readmeInfo": "Acerca de este README",
"settings.plugins.market.installed": "Instalado",
"settings.plugins.market.sourceBadge": "Ejemplo de código",
"settings.plugins.market.title": "Catálogo",
"settings.plugins.market.note": "Plugins de código abierto seleccionados. La revisión no certifica su seguridad. Las actualizaciones son manuales.",
"settings.plugins.market.back": "Todos los plugins",
"settings.plugins.market.install": "Instalar esta versión (desactivada)",
"settings.plugins.market.installNote": "Esto reemplaza y detiene la versión instalada. Revisa sus permisos antes de activarla.",
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
"settings.plugins.manager.sensitive": "Acceso sensible: revisa los datos y el alcance antes de autorizar.",
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
"settings.plugins.market.retry": "Reintentar",
"settings.plugins.market.empty": "No hay entradas en el catálogo. Puedes importar un plugin local de navegador.",
"settings.plugins.market.emptyReadme": "Este README está vacío.",
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
"settings.plugins.market.manifest": "Compatibilidad y permisos",
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
"settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
'settings.plugins.scopeContext': 'Abre este ajuste en su contexto de {scope}.',
-3
View File
@@ -965,9 +965,6 @@ export const esVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
'hostDetails.backspaceBehavior.default': 'Predeterminado',
'hostDetails.jumpHosts': 'Proxy mediante hosts',
+117 -2
View File
@@ -1130,8 +1130,123 @@ export const ruCoreMessages: Messages = {
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
'settings.tab.plugins': 'Плагины',
'settings.plugins.title': 'Настройки плагинов',
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
"settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
"settings.plugins.manager.enabled": "Включён",
"settings.plugins.manager.disabled": "Отключён",
"settings.plugins.manager.enable": "Включить",
"settings.plugins.manager.disable": "Отключить",
"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": "Ввести текст",
"plugins.interaction.filter": "Фильтр вариантов",
"plugins.interaction.preview": "Предпросмотр буфера обмена",
"plugins.interaction.previewNote": "Буфер читается только после нажатия предпросмотра. Затем проверьте текст и отдельно подтвердите передачу.",
"plugins.interaction.previewAction": "Предпросмотр буфера обмена",
"plugins.interaction.share": "Передать плагину",
"plugins.interaction.copy": "Копировать в буфер обмена",
"plugins.interaction.failed": "Запрос истёк или не выполнен.",
"settings.plugins.permissions.current": "Соответствует текущему запуску",
"settings.plugins.permissions.inactive": "Не действует для текущего запуска",
"settings.plugins.permissions.title": "Сохранённые разрешения",
"settings.plugins.permissions.refresh": "Обновить",
"settings.plugins.permissions.loading": "Загрузка разрешений...",
"settings.plugins.permissions.empty": "Сохранённых разрешений нет. Одноразовые решения не сохраняются.",
"settings.plugins.permissions.always": "До отзыва",
"settings.plugins.permissions.application": "До выхода из Netcatty",
"settings.plugins.permissions.session": "До закрытия сеанса",
"settings.plugins.permissions.audit": "Последние события безопасности (до 100)",
"settings.plugins.permissions.revoke": "Отключить и отозвать все разрешения",
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
"settings.plugins.themes.title": "Темы плагинов",
"settings.plugins.themes.description": "Предпросмотр меняет только это окно. Применение сохраняет выбор на этом устройстве. Личные настройки сохраняются.",
"settings.plugins.themes.empty": "Нет включённых тем для этого режима.",
"settings.plugins.themes.choose": "Выберите тему",
"settings.plugins.themes.unavailable": "Сохранённая тема недоступна; используется стандартная палитра",
"settings.plugins.themes.applied": "Применено",
"settings.plugins.themes.preview": "Предпросмотр",
"settings.plugins.themes.apply": "Применить",
"settings.plugins.themes.cancel": "Отменить предпросмотр",
"settings.plugins.themes.restore": "Сбросить тему",
"settings.plugins.themes.invalid": "Некоторые палитры недоступны или отклонены.",
"settings.plugins.themes.failed": "Не удалось применить тему.",
"settings.plugins.manager.dataTheme": "Пакет тем: проверенные цвета без выполнения кода.",
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.sources.offline": "Источник недоступен. Ранее проверенный каталог этого источника, если он есть в кэше, доступен только для просмотра. Установка недоступна до успешного обновления.",
"settings.plugins.sources.note": "Этот каталог предоставляет выбранная третья сторона; Netcatty не отбирает его содержимое. Перед установкой проверьте домен источника и разрешения. Обновления выполняются вручную.",
"settings.plugins.sources.sourceOnly": "Запись с исходным кодом от этого поставщика. Пакет для загрузки отсутствует; см. инструкции автора по сборке и импорту.",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
"settings.plugins.status": "Уведомления: {count}",
"settings.plugins.info": "Информация о плагинах",
"settings.plugins.manager.noVaultShort": "Хранилище секретов недоступно",
"settings.plugins.market.offlineShort": "Каталог офлайн · установка недоступна",
"settings.plugins.market.readmeInfo": "Об этом README",
"settings.plugins.market.installed": "Установлен",
"settings.plugins.market.sourceBadge": "Пример исходного кода",
"settings.plugins.market.title": "Каталог",
"settings.plugins.market.note": "Отобранные плагины с открытым кодом. Проверка не гарантирует безопасность. Обновления выполняются вручную.",
"settings.plugins.market.back": "Все плагины",
"settings.plugins.market.install": "Установить эту версию (отключённой)",
"settings.plugins.market.installNote": "Установленная версия будет заменена и остановлена. Проверьте разрешения перед включением.",
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
"settings.plugins.market.retry": "Повторить",
"settings.plugins.market.empty": "Каталог пуст. Можно импортировать локальный браузерный плагин.",
"settings.plugins.market.emptyReadme": "Этот README пуст.",
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
"settings.plugins.market.manifest": "Совместимость и разрешения",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.sources.conflict": "This ID is installed from another source. Uninstall and explicitly reset its private data before switching. Existing source:",
"settings.plugins.sources.clearPrivate": "Also clear this source’s private data, grants and secrets to reinstall from another source. This cannot be undone.",
"settings.plugins.sources.official": "Official marketplace",
"settings.plugins.sources.thirdparty": "Third-party marketplace",
"settings.plugins.sources.manage": "Manage sources",
"settings.plugins.sources.select": "Select a source",
"settings.plugins.sources.empty": "Choose or add a third-party source.",
"settings.plugins.sources.add": "Add source",
"settings.plugins.sources.remove": "Remove source",
"settings.plugins.sources.name": "Source name",
"settings.plugins.sources.address": "HTTPS source address",
"settings.plugins.sources.risk": "Third-party sources are not maintained or endorsed by Netcatty. Review the real domain, package permissions and author before installing.",
"settings.plugins.sources.accept": "I trust this domain to supply plugin metadata and packages. This does not grant plugin permissions.",
"settings.plugins.sources.removeNote": "Installed plugins and their data remain. Updates from this source stop; another source will not replace it.",
"settings.plugins.sources.disablePlugins": "Also disable plugins installed from this source",
"settings.plugins.sources.installConfirm": "Review third-party installation",
"settings.plugins.sources.source": "Source",
"settings.plugins.sources.local": "Local import",
"settings.plugins.sources.all": "All sources",
"settings.plugins.title": "Плагины",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
'settings.plugins.scopeContext': 'Откройте эту настройку в контексте {scope}.',
-3
View File
@@ -1014,9 +1014,6 @@ export const ruVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
'hostDetails.backspaceBehavior': 'Поведение Backspace',
'hostDetails.backspaceBehavior.default': 'По умолчанию',
'hostDetails.jumpHosts': 'Прокси через хосты',
+117 -2
View File
@@ -1150,8 +1150,123 @@ Vaults 标签保持扁平,不使用这块背景。
'qs.localShells': '本地 Shell',
'qs.default': '默认',
'settings.tab.plugins': '插件',
'settings.plugins.title': '插件设置',
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
"settings.plugins.manager.installed": "已安装插件",
"settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
"settings.plugins.manager.enabled": "已启用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "启用",
"settings.plugins.manager.disable": "停用",
"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": "输入文本",
"plugins.interaction.filter": "筛选选项",
"plugins.interaction.preview": "剪贴板预览",
"plugins.interaction.previewNote": "仅在点击“预览剪贴板”后读取。检查文本后,还需单独确认才能分享给插件。",
"plugins.interaction.previewAction": "预览剪贴板",
"plugins.interaction.share": "分享给插件",
"plugins.interaction.copy": "复制到剪贴板",
"plugins.interaction.failed": "此请求已过期或无法完成。",
"settings.plugins.permissions.current": "匹配当前运行实例",
"settings.plugins.permissions.inactive": "对当前运行实例不生效",
"settings.plugins.permissions.title": "已保存的授权",
"settings.plugins.permissions.refresh": "刷新",
"settings.plugins.permissions.loading": "正在读取授权...",
"settings.plugins.permissions.empty": "没有已保存的授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
"settings.plugins.themes.title": "插件主题",
"settings.plugins.themes.description": "预览仅影响当前窗口;应用后保存在本设备。个人强调色、字体和终端覆盖设置仍然优先。",
"settings.plugins.themes.empty": "当前模式没有已启用的插件主题。",
"settings.plugins.themes.choose": "选择主题",
"settings.plugins.themes.unavailable": "已保存的主题不可用,正在使用宿主默认配色",
"settings.plugins.themes.applied": "已应用",
"settings.plugins.themes.preview": "预览",
"settings.plugins.themes.apply": "应用",
"settings.plugins.themes.cancel": "取消预览",
"settings.plugins.themes.restore": "恢复默认",
"settings.plugins.themes.invalid": "部分主题配色未通过验证或不可用。",
"settings.plugins.themes.failed": "无法应用主题。",
"settings.plugins.manager.dataTheme": "主题包:已验证配色,不启动代码运行时。",
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.sources.offline": "来源暂不可用。如已缓存此来源通过校验的目录,仅可继续浏览;刷新成功前无法安装。",
"settings.plugins.sources.note": "此目录由所选第三方提供,不属于 Netcatty 精选目录。安装前请核对来源域名和权限,更新需手动确认。",
"settings.plugins.sources.sourceOnly": "此提供方仅提供源码条目,没有可下载插件包;请查看作者的构建和导入说明。",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
"settings.plugins.status": "{count} 项提醒",
"settings.plugins.info": "插件说明",
"settings.plugins.manager.noVaultShort": "凭据存储不可用",
"settings.plugins.market.offlineShort": "目录离线 · 暂不可安装",
"settings.plugins.market.readmeInfo": "README 来源说明",
"settings.plugins.market.installed": "已安装",
"settings.plugins.market.sourceBadge": "源码示例",
"settings.plugins.market.title": "插件市场",
"settings.plugins.market.note": "精选开源插件。审核不代表安全认证,更新由你手动确认。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.install": "安装此版本(保持停用)",
"settings.plugins.market.installNote": "这会替换并停止已安装版本。请在已安装列表检查权限后再启用。",
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
"settings.plugins.market.retry": "重试",
"settings.plugins.market.empty": "目录暂无插件,仍可导入本地浏览器插件。",
"settings.plugins.market.emptyReadme": "README 内容为空。",
"settings.plugins.market.search": "搜索名称、作者或描述",
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
"settings.plugins.market.manifest": "兼容性与权限",
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。',
'settings.plugins.scopeContext': '请在对应的 {scope} 上下文中打开此设置。',
-3
View File
@@ -384,9 +384,6 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
'hostDetails.backspaceBehavior': 'Backspace 行为',
'hostDetails.backspaceBehavior.default': '默认',
'hostDetails.jumpHosts': '通过主机代理',
+117 -2
View File
@@ -1153,9 +1153,124 @@ Vaults 分頁維持扁平,不使用這塊背景。
'qs.default': '預設',
'settings.tab.plugins': '外掛程式',
'settings.plugins.title': '外掛程式設定',
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
"settings.plugins.manager.enabled": "已啟用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "啟用",
"settings.plugins.manager.disable": "停用",
"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": "輸入文字",
"plugins.interaction.filter": "篩選選項",
"plugins.interaction.preview": "剪貼簿預覽",
"plugins.interaction.previewNote": "僅在點選「預覽剪貼簿」後讀取。檢查文字後,還需另外確認才能分享給外掛。",
"plugins.interaction.previewAction": "預覽剪貼簿",
"plugins.interaction.share": "分享給外掛",
"plugins.interaction.copy": "複製到剪貼簿",
"plugins.interaction.failed": "此請求已過期或無法完成。",
"settings.plugins.permissions.current": "符合目前執行個體",
"settings.plugins.permissions.inactive": "對目前執行個體不生效",
"settings.plugins.permissions.title": "已儲存的授權",
"settings.plugins.permissions.refresh": "重新整理",
"settings.plugins.permissions.loading": "正在讀取授權...",
"settings.plugins.permissions.empty": "沒有已儲存的授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
"settings.plugins.themes.title": "外掛主題",
"settings.plugins.themes.description": "預覽僅影響目前視窗;套用後儲存於本機。個人強調色、字型和終端覆寫設定仍然優先。",
"settings.plugins.themes.empty": "目前模式沒有已啟用的外掛主題。",
"settings.plugins.themes.choose": "選擇主題",
"settings.plugins.themes.unavailable": "已儲存的主題無法使用,正在使用宿主預設配色",
"settings.plugins.themes.applied": "已套用",
"settings.plugins.themes.preview": "預覽",
"settings.plugins.themes.apply": "套用",
"settings.plugins.themes.cancel": "取消預覽",
"settings.plugins.themes.restore": "恢復預設",
"settings.plugins.themes.invalid": "部分主題配色未通過驗證或無法使用。",
"settings.plugins.themes.failed": "無法套用主題。",
"settings.plugins.manager.dataTheme": "主題套件:已驗證配色,不啟動程式執行環境。",
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.sources.conflict": "此 ID 已从其他来源安装。请先卸载并明确清理旧来源数据,再换源重装。原来源:",
"settings.plugins.sources.clearPrivate": "同时清除本来源的私有数据、授权和秘密,以便显式换源重装。此操作不可撤销。",
"settings.plugins.sources.official": "官方市场",
"settings.plugins.sources.thirdparty": "第三方市场",
"settings.plugins.sources.manage": "管理来源",
"settings.plugins.sources.select": "选择来源",
"settings.plugins.sources.empty": "选择或添加第三方来源。",
"settings.plugins.sources.add": "添加来源",
"settings.plugins.sources.remove": "移除来源",
"settings.plugins.sources.name": "来源名称",
"settings.plugins.sources.address": "HTTPS 来源地址",
"settings.plugins.sources.risk": "第三方来源不代表 Netcatty 维护或认可。安装前请核对真实域名、插件权限与作者。",
"settings.plugins.sources.accept": "我信任此域名提供插件目录和软件包。此操作不会授予插件权限。",
"settings.plugins.sources.removeNote": "保留已安装插件及数据,停止此来源更新,不会自动切换到其他来源。",
"settings.plugins.sources.disablePlugins": "同时停用来自该来源的插件",
"settings.plugins.sources.installConfirm": "确认第三方安装",
"settings.plugins.sources.source": "来源",
"settings.plugins.sources.local": "本地导入",
"settings.plugins.sources.all": "全部来源",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.sources.offline": "來源暫不可用。如已快取此來源通過驗證的目錄,僅可繼續瀏覽;重新整理成功前無法安裝。",
"settings.plugins.sources.note": "此目錄由所選第三方提供,不屬於 Netcatty 精選目錄。安裝前請核對來源網域和權限,更新需手動確認。",
"settings.plugins.sources.sourceOnly": "此提供方僅提供原始碼項目,沒有可下載外掛套件;請查看作者的建置和匯入說明。",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
"settings.plugins.status": "{count} 項提醒",
"settings.plugins.info": "外掛說明",
"settings.plugins.manager.noVaultShort": "憑據儲存無法使用",
"settings.plugins.market.offlineShort": "目錄離線 · 暫時無法安裝",
"settings.plugins.market.readmeInfo": "README 來源說明",
"settings.plugins.market.installed": "已安裝",
"settings.plugins.market.sourceBadge": "原始碼範例",
"settings.plugins.market.title": "插件市場",
"settings.plugins.market.note": "精選開源插件。審核不代表安全認證,更新由你手動確認。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.install": "安裝此版本(保持停用)",
"settings.plugins.market.installNote": "這會替換並停止已安裝版本。請在已安裝列表檢查權限後再啟用。",
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
"settings.plugins.market.retry": "重試",
"settings.plugins.market.empty": "目錄暫無插件,仍可導入本地瀏覽器插件。",
"settings.plugins.market.emptyReadme": "README 內容為空。",
"settings.plugins.market.search": "搜索名稱、作者或描述",
"settings.plugins.market.noResults": "沒有匹配插件,請嘗試其他關鍵詞。",
"settings.plugins.market.manifest": "兼容性與權限",
'settings.plugins.empty': '目前沒有已啟用外掛程式提供設定。',
'settings.plugins.scopeContext': '請在對應的 {scope} 情境中開啟此設定。',
'settings.plugins.pressKeybinding': '請按下組合鍵',
-3
View File
@@ -384,9 +384,6 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
'hostDetails.backspaceBehavior': 'Backspace 行為',
'hostDetails.backspaceBehavior.default': '預設',
'hostDetails.jumpHosts': '透過主機代理',
+27
View File
@@ -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;
}
+18
View File
@@ -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;
});
}
@@ -1,3 +1,4 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import {
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
isLegacyDefaultShiftEnterNewlineText,
@@ -252,6 +253,9 @@ export const applyThemeTokens = (
accentOverride: string,
) => {
const root = window.document.documentElement;
root.toggleAttribute('data-plugin-theme-active', pluginThemeRegistry.isActive(resolvedTheme));
const securityVars = buildAppThemeCssVars(pluginThemeRegistry.securityTokens(resolvedTheme), 'theme', '');
for (const [key, value] of Object.entries(securityVars)) root.style.setProperty('--host-security' + key.slice(1), value);
root.classList.remove('light', 'dark');
root.classList.add(resolvedTheme);
const cssVars = buildAppThemeCssVars(tokens, accentMode, accentOverride);
+2 -2
View File
@@ -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',
+8 -2
View File
@@ -1,3 +1,6 @@
import { pluginThemeRegistry } from '../../infrastructure/config/pluginThemeRegistry';
import { usePluginThemeRevision } from './usePluginThemeRuntime';
import { useSettingsChromeStore } from './settingsChromeStore';
import { useLayoutEffect, useRef } from "react";
import type { TerminalTheme } from "../../domain/models";
import {
@@ -271,12 +274,15 @@ export function useActiveChromeTheme({
activeTheme: TerminalTheme | null;
applyAppTheme: () => void;
}) {
const pluginThemeRevision = usePluginThemeRevision();
const { resolvedTheme } = useSettingsChromeStore();
const effectiveTheme = pluginThemeRegistry.isActive(resolvedTheme) ? null : activeTheme;
const applyAppThemeRef = useRef(applyAppTheme);
applyAppThemeRef.current = applyAppTheme;
useLayoutEffect(() => {
syncActiveChromeTheme(activeTheme, applyAppTheme);
}, [activeTheme, applyAppTheme]);
syncActiveChromeTheme(effectiveTheme, applyAppTheme);
}, [effectiveTheme, applyAppTheme, pluginThemeRevision]);
useLayoutEffect(() => {
return () => {
+119
View File
@@ -0,0 +1,119 @@
import { useEffect, useRef, useState } from 'react';
import { PluginViewLifecycleController, type HostedPluginViewState } from './pluginViewLifecycle';
import { resolvePluginViewWindowScope } from './pluginViewScopes';
import { isPluginViewOccluded } from './pluginViewOcclusion';
import type { UsePluginContributionsResult } from './usePluginContributions';
type ViewAdapter = Pick<UsePluginContributionsResult, 'openView' | 'closeView' | 'focusView' | 'setViewBounds' | 'setViewVisibility' | 'onViewClosed' | 'onViewFocused'>;
/** Host-owned mounting only. No plugin router, DOM, session or vault data API. */
export function useEmbeddedPluginView({ viewId, version, slot, active, context, adapter, subscribeHidden, unavailable, focused, focusOnReveal = false }: {
viewId: string;
version: string;
slot: string;
active: boolean;
context: Record<string, unknown>;
adapter: ViewAdapter;
subscribeHidden(listener: () => void): () => void;
unavailable(): void;
focused?(): void;
focusOnReveal?: boolean;
}) {
const mountRef = useRef<HTMLDivElement>(null);
const [loading, setLoading] = useState(true);
const adapterRef = useRef(adapter);
const unavailableRef = useRef(unavailable);
const focusedRef = useRef(focused);
const revealFocusRef = useRef(focusOnReveal);
const requestFocusRef = useRef<() => void>(() => {});
revealFocusRef.current = focusOnReveal;
focusedRef.current = focused;
adapterRef.current = adapter;
unavailableRef.current = unavailable;
useEffect(() => {
const mount = mountRef.current;
if (!active || !mount) return;
let cancelled = false;
const lifecycle = new PluginViewLifecycleController<HostedPluginViewState>();
const instanceId = crypto.randomUUID();
const scopeId = resolvePluginViewWindowScope({ pathname: window.location.pathname,
search: window.location.search, hash: `${window.location.hash}|plugin-slot:${slot}` });
const { openView, closeView, focusView, setViewBounds, setViewVisibility, onViewClosed, onViewFocused } = adapterRef.current;
const close = () => {
if (cancelled) return;
cancelled = true;
lifecycle.markViewClosed(viewId);
void closeView(instanceId).catch(() => {});
};
const failCurrent = () => {
if (cancelled) return;
close();
unavailableRef.current();
};
const unsubscribe = onViewClosed((event) => {
lifecycle.handleHostClose(event.instanceId);
if (event.instanceId === instanceId && !cancelled) {
close();
unavailableRef.current();
}
});
const stopOnHide = subscribeHidden(close);
const stopOnFocus = onViewFocused((event) => {
if (!cancelled && event.instanceId === instanceId) focusedRef.current?.();
});
const bounds = () => {
const rect = mount.getBoundingClientRect();
return { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.max(1, Math.round(rect.width)), height: Math.max(1, Math.round(rect.height)) };
};
let opened = false;
let lastBounds = '';
let visible = false;
const focus = () => {
if (!cancelled && opened && visible && revealFocusRef.current && !isPluginViewOccluded(mount)) void focusView(instanceId).catch(() => {});
};
requestFocusRef.current = focus;
const updateVisibility = () => {
if (!opened || cancelled) return;
const rect = mount.getBoundingClientRect();
const next = rect.width > 0 && rect.height > 0 && !isPluginViewOccluded(mount);
if (visible === next) return;
visible = next;
void setViewVisibility(instanceId, next).then(() => { if (next) focus(); }).catch(failCurrent);
};
const resize = () => {
if (opened && !cancelled) {
const next = bounds();
const key = JSON.stringify(next);
if (key !== lastBounds) { lastBounds = key; void setViewBounds(instanceId, next).catch(failCurrent); }
}
updateVisibility();
};
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(resize);
observer?.observe(mount);
const mutations = new MutationObserver(resize);
mutations.observe(document.body, { childList: true, subtree: true, attributes: true,
attributeFilter: ['class', 'style', 'hidden', 'inert', 'role', 'aria-modal', 'aria-hidden', 'data-state', 'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'data-native-view-occluder'] });
window.addEventListener('resize', resize);
document.addEventListener('scroll', resize, true);
setLoading(true);
const opening = lifecycle.beginOpen({ viewKey: viewId });
void openView({ viewId, instanceId, scopeId, bounds: bounds(), context }).then(async (result) => {
if (cancelled || lifecycle.shouldCloseOpen(opening) || lifecycle.consumeHostClose(result.instanceId)) {
await closeView(result.instanceId);
return;
}
opened = true;
lifecycle.setCurrent({ id: result.instanceId, viewId, scopeId, retainContextWhenHidden: false });
resize();
setLoading(false);
}).catch(() => {
if (!cancelled) { close(); unavailableRef.current(); }
}).finally(() => lifecycle.finishOpen({ token: opening, viewKey: viewId }));
return () => {
close(); unsubscribe(); stopOnHide(); stopOnFocus(); observer?.disconnect(); mutations.disconnect();
window.removeEventListener('resize', resize); document.removeEventListener('scroll', resize, true);
};
}, [active, context, slot, subscribeHidden, version, viewId]);
useEffect(() => { if (focusOnReveal) requestFocusRef.current(); }, [focusOnReveal]);
return { mountRef, loading };
}
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type Raster = { light: string; dark?: string };
export function usePluginBrandIcon(plugin?: NetcattyInstalledPlugin): Raster | null {
const pluginId = plugin?.id;
const kind = plugin?.manifest?.icon?.kind;
const key = JSON.stringify([pluginId, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, plugin?.manifest?.icon]);
const [loaded, setLoaded] = useState<{ key: string; value: Raster | null } | null>(null);
useEffect(() => {
if (!pluginId || kind !== 'package') return;
let cancelled = false;
void netcattyBridge.get()?.getPluginBrandIcon?.(pluginId).then((value) => {
if (!cancelled) setLoaded({ key, value });
}).catch(() => { if (!cancelled) setLoaded({ key, value: null }); });
return () => { cancelled = true; };
}, [key, pluginId, kind]);
return loaded?.key === key ? loaded.value : null;
}
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { usePluginCatalog } from './usePluginCatalog';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (cause: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
test('README failures clear on retry/switch and stale requests cannot replace the selected entry', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const calls: Array<{ id: string; request: ReturnType<typeof deferred<string>> }> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: {
listPluginCatalog: async () => ({ entries: [], source: "builtin", error: null }),
getPluginCatalogReadme: (id: string) => { const request = deferred<string>(); calls.push({ id, request }); return request.promise; },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
let renders = 0;
function Probe({ id }: { id: string }) { state = usePluginCatalog(id); renders++; return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe, { id: 'first' })); });
assert.equal(state.readmeStatus, 'loading');
await act(async () => calls[0].request.reject(new Error('temporary failure')));
assert.equal(state.readmeStatus, 'error');
assert.match(state.readmeError!, /temporary failure/);
await act(async () => state.retryReadme());
assert.equal(state.readmeStatus, 'loading'); assert.equal(state.readmeError, null);
await act(async () => calls[1].request.resolve(''));
assert.equal(state.readmeStatus, 'ready'); assert.equal(state.readme, ''); assert.equal(state.readmeError, null);
await act(async () => state.retryReadme());
await act(async () => renderer.update(React.createElement(Probe, { id: 'second' })));
assert.equal(state.readmeError, null);
await act(async () => calls[3].request.resolve('# Second'));
await act(async () => calls[2].request.reject(new Error('stale failure')));
assert.equal(state.readme, '# Second'); assert.equal(state.readmeError, null);
await act(async () => state.retryReadme());
await act(async () => renderer.unmount());
const unmountedRenders = renders;
await act(async () => calls[4].request.resolve('late content'));
assert.equal(renders, unmountedRenders);
});
test('offline snapshot stays browsable, retry clears warning, and operations bind the selected token', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const entry = { id: 'com.test.registry', selectionToken: 'first' } as NetcattyPluginCatalogEntry;
let offline = true;
const reads: unknown[][] = []; const installs: unknown[][] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
dispatchEvent() {}, netcatty: {
listPluginCatalog: async () => ({ entries: [{ ...entry, selectionToken: offline ? 'first' : 'second' }], source: offline ? 'cached' : 'registry', error: offline ? 'offline' : null }),
getPluginCatalogReadme: async (...args: unknown[]) => { reads.push(args); return '# Pinned'; },
installCatalogPlugin: async (...args: unknown[]) => { installs.push(args); },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
function Probe() { state = usePluginCatalog(entry.id); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(state.entries.length, 1); assert.equal(state.listStatus, 'ready'); assert.equal(state.listError, 'offline');
assert.equal(state.source, 'cached');
offline = false;
await act(async () => state.retryList());
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
assert.deepEqual(reads.at(-1), [entry.id, 'second', 'official']);
await act(async () => state.install(entry.id));
assert.deepEqual(installs, [[entry.id, 'second', 'official', undefined]]);
await act(async () => renderer.unmount());
});
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
export function usePluginCatalog(selectedId: string | null, sourceId = 'official') {
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
const [listError, setListError] = useState<string | null>(null);
const [listAttempt, setListAttempt] = useState(0);
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const [sourceBinding, setSourceBinding] = useState<NetcattyPluginSource | null>(null);
const installing = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
useEffect(() => {
let active = true;
setListStatus('loading'); setListError(null);
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
const list = await bridge.listPluginCatalog(sourceId);
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
})();
return () => { active = false; };
}, [listAttempt, sourceId]);
useEffect(() => {
let active = true;
setSourceBinding(null);
if (selectedId) void netcattyBridge.get()?.getPluginSourceBinding?.(selectedId).then((binding) => { if (active) setSourceBinding(binding); }).catch((cause) => { if (active) setInstallError({ id: selectedId, message: String(cause) }); });
return () => { active = false; };
}, [selectedId, sourceId]);
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
useEffect(() => {
if (!selectedId) return;
let active = true;
const base = { id: selectedId, token: selectionToken, attempt: readmeAttempt, text: '', error: null };
setReadmeState({ ...base, status: 'loading' });
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken, sourceId);
if (active) setReadmeState({ ...base, status: 'ready', text });
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt, sourceId]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source, sourceBinding,
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
error: installError?.id === selectedId ? installError.message : null,
busy,
retryList: () => setListAttempt((attempt) => attempt + 1),
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
async install(id: string, acceptedSourceId?: string, clearPreviousSource = false) {
if (installing.current) return;
installing.current = true;
setBusy(true); setInstallError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
if (clearPreviousSource && sourceBinding && sourceBinding.id !== sourceId) {
if (!bridge.resetPluginSource) throw new Error('Source reset unavailable');
await bridge.resetPluginSource(id, sourceBinding.id);
setSourceBinding(null);
}
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken, sourceId, acceptedSourceId);
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
finally { installing.current = false; if (mounted.current) setBusy(false); }
},
};
}
@@ -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,53 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { HostInteractionPrompt, HostInteractionResponse } from '@netcatty/plugin-contract';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginHostInteractions() {
const [prompt, setPrompt] = useState<HostInteractionPrompt | null>(null);
const [value, setValue] = useState('');
const [error, setError] = useState(false);
const [busy, setBusy] = useState(false);
const current = useRef<HostInteractionPrompt | null>(null);
const pending = useRef(false);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onPluginInteraction || !bridge.setPluginInteractionReady) return;
const unsubscribe = bridge.onPluginInteraction((event) => {
if ('closed' in event) {
if (current.current?.requestId !== event.requestId) return;
current.current = null; setPrompt(null);
} else {
current.current = event; setPrompt(event);
setValue(event.request.kind === 'input' ? event.request.initialValue ?? '' : '');
}
pending.current = false; setBusy(false); setError(false);
});
void bridge.setPluginInteractionReady(true).catch(() => {});
return () => {
current.current = null;
unsubscribe();
void bridge.setPluginInteractionReady?.(false).catch(() => {});
};
}, []);
const respond = useCallback(async (action: HostInteractionResponse['action']) => {
const active = current.current;
if (!active || pending.current) return;
pending.current = true; setBusy(true); setError(false);
try {
const bridge = netcattyBridge.require();
if (!bridge.respondPluginInteraction) throw new Error('Host interactions unavailable');
const accepted = await bridge.respondPluginInteraction({
requestId: active.requestId, actionToken: active.actionToken, action,
...(action === 'submit' && (active.request.kind === 'input' || active.request.kind === 'quickPick') ? { value } : {}),
});
if (!accepted && current.current?.actionToken === active.actionToken) {
current.current = null; setPrompt(null);
}
} catch {
if (current.current?.actionToken === active.actionToken) setError(true);
} finally {
if (current.current?.actionToken === active.actionToken) { pending.current = false; setBusy(false); }
}
}, [value]);
return { prompt, value, setValue, error, busy, respond };
}
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { usePluginManager } from './usePluginManager';
test('failed manager refresh marks previous enabled state unknown until a successful read', async (context) => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
const actPrevious = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
let fail = false;
const plugin = { id: 'com.example.synthetic', enabled: true } as NetcattyInstalledPlugin;
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
addEventListener() {}, removeEventListener() {}, netcatty: {
async getPluginRuntimeStatus() { return { available: true }; },
async listPlugins() { if (fail) throw new Error('synthetic status read failed'); return [plugin]; },
},
} });
context.after(() => {
if (previous) Object.defineProperty(globalThis, 'window', previous); else Reflect.deleteProperty(globalThis, 'window');
if (actPrevious) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', actPrevious); else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginManager>;
function Probe() { state = usePluginManager(); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
try {
assert.equal(state.statusKnown, true);
assert.equal(state.plugins[0].enabled, true);
fail = true;
await act(async () => { await assert.rejects(state.refresh(), /synthetic status read failed/); });
assert.equal(state.statusKnown, false);
assert.match(state.error ?? '', /synthetic status read failed/);
fail = false; plugin.enabled = false;
await act(async () => { await state.refresh(); });
assert.equal(state.statusKnown, true);
assert.equal(state.plugins[0].enabled, false);
} finally { await act(async () => { renderer.unmount(); }); }
});
+74
View File
@@ -0,0 +1,74 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginManager() {
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
const [available, setAvailable] = useState(false);
const [statusKnown, setStatusKnown] = useState(false);
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async () => {
const current = ++generation.current;
try {
const bridge = netcattyBridge.get();
const status = await bridge?.getPluginRuntimeStatus?.();
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setStatusKnown(true);
setCredentialStorageAvailable(status?.credentialStorageAvailable);
setPlugins(list);
} catch (cause) {
if (current === generation.current) {
setStatusKnown(false);
setError(cause instanceof Error ? cause.message : String(cause));
}
throw cause;
}
}, []);
useEffect(() => {
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
update();
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
window.addEventListener('netcatty:plugin-packages-changed', update);
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
try { await action(netcattyBridge.require()); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally {
try { await refresh(); } catch (cause) { setError(String(cause)); }
mutation.current = false;
setBusy(false);
}
}, [refresh]);
return {
plugins, available, statusKnown, busy, error, credentialStorageAvailable, refresh,
importPackage: () => run(async (bridge) => {
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
if (path) await bridge.installPluginPackage(path, { enable: false });
}),
setEnabled: (id: string, enabled: boolean) => run(async (bridge) => {
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
return bridge.setPluginEnabled(id, enabled);
}),
uninstall: (id: string, clearSource = false) => run(async (bridge) => {
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
const source = clearSource ? await bridge.getPluginSourceBinding?.(id) : null;
await bridge.uninstallPlugin(id);
if (clearSource && source) {
if (!bridge.resetPluginSource) throw new Error('Private source reset unavailable');
await bridge.resetPluginSource(id, source.id);
}
}),
};
}
+25
View File
@@ -0,0 +1,25 @@
import { useCallback, useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginSources() {
const [sources, setSources] = useState<NetcattyPluginSource[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
const values = await netcattyBridge.require().listPluginSources?.();
if (!values) throw new Error('Source management unavailable');
setSources(values);
}, []);
useEffect(() => { void reload().catch((cause) => setError(String(cause))); }, [reload]);
const mutate = async (operation: () => Promise<unknown>) => {
setBusy(true); setError(null);
try { await operation(); await reload(); window.dispatchEvent(new Event('netcatty:plugin-packages-changed')); return true; }
catch (cause) { setError(String(cause)); return false; }
finally { setBusy(false); }
};
return { sources, error, busy,
add: (name: string, address: string) => mutate(async () => netcattyBridge.require().addPluginSource?.({ name, address })),
update: (source: NetcattyPluginSource, name: string, enabled: boolean) => mutate(async () => netcattyBridge.require().updatePluginSource?.({ id: source.id, name, enabled })),
remove: (id: string, disablePlugins: boolean) => mutate(async () => netcattyBridge.require().removePluginSource?.(id, disablePlugins)),
};
}
@@ -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,77 @@
import { useEffect, useSyncExternalStore } from "react";
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
import { pluginThemeRegistry } from "../../infrastructure/config/pluginThemeRegistry";
export function usePluginThemeRevision() {
return useSyncExternalStore(
pluginThemeRegistry.subscribe,
pluginThemeRegistry.getRevision,
pluginThemeRegistry.getRevision,
);
}
/** Host-owned selection; never stored in plugin KV or cloud sidecars. */
let runtimeUsers = 0;
let disconnectRuntime: (() => void) | undefined;
function connectRuntime() {
const bridge = netcattyBridge.get();
let active = true,
request = 0;
const refresh = async () => {
const current = ++request;
try {
const catalog = await bridge?.getPluginThemes?.();
if (active && current === request && catalog)
pluginThemeRegistry.setCatalog(catalog);
} catch {
/* Keep the last host-validated selection; never apply unvalidated data. */
}
};
void refresh();
const dispose = bridge?.onPluginThemesChanged?.(() => {
pluginThemeRegistry.invalidate();
void refresh();
});
const forced = window.matchMedia?.("(forced-colors: active)"),
contrast = window.matchMedia?.("(prefers-contrast: more)");
const update = () =>
pluginThemeRegistry.setHighContrast(
Boolean(forced?.matches || contrast?.matches),
);
update();
forced?.addEventListener?.("change", update);
contrast?.addEventListener?.("change", update);
return () => {
active = false;
request++;
dispose?.();
forced?.removeEventListener?.("change", update);
contrast?.removeEventListener?.("change", update);
};
}
export function usePluginThemeRuntime() {
const revision = usePluginThemeRevision();
useEffect(() => {
if (runtimeUsers++ === 0) disconnectRuntime = connectRuntime();
return () => {
if (--runtimeUsers === 0) {
disconnectRuntime?.();
disconnectRuntime = undefined;
}
};
}, []);
return revision;
}
export async function applyPluginThemeSelection(
mode: "light" | "dark",
choice: import("@netcatty/plugin-contract").ThemePackChoice | null,
) {
if (choice === null && !pluginThemeRegistry.isActive(mode))
return pluginThemeRegistry.getCatalog();
const bridge = netcattyBridge.require();
if (!bridge.applyPluginTheme) throw new Error("Plugin themes unavailable");
const next = await bridge.applyPluginTheme({
mode,
choice,
expectedRevision: pluginThemeRegistry.getCatalog().selection.revision,
});
return next; // The host change event refreshes every window; never publish a potentially stale apply response.
}
+47
View File
@@ -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 };
}
+1 -1
View File
@@ -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({
+5 -2
View File
@@ -1,3 +1,4 @@
import { usePluginThemeRuntime } from './usePluginThemeRuntime';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
import { runThemeTransition, type ThemeTransitionMode } from './themeTransition';
@@ -217,6 +218,7 @@ import {
} from './appearanceSync';
export const useSettingsState = (options: { enableSettingsSync?: boolean; enableSystemEffects?: boolean } = {}) => {
const pluginThemeRevision = usePluginThemeRuntime();
const enableSettingsSync = options.enableSettingsSync !== false;
const enableSystemEffects = options.enableSystemEffects !== false;
const initialCustomKeyBindingsRecord =
@@ -1212,7 +1214,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
notifySettingsChanged(STORAGE_KEY_COLOR, customAccentRecord);
}
}
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentRecord, notifySettingsChanged, pluginThemeRevision]);
// Listen for OS color scheme changes to keep systemPreference in sync
useEffect(() => {
@@ -1979,9 +1981,10 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
}, [setTerminalSettings]);
const applyAppTheme = useCallback(() => {
void pluginThemeRevision; // Invalidate the closure when host theme data changes.
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, pluginThemeRevision]);
const setEditorWordWrap = useCallback((enabled: boolean) => {
setEditorWordWrapState(enabled);
-1
View File
@@ -335,7 +335,6 @@ export const useTerminalBackend = () => {
contextViewportSnapshot: string;
contextScrollbackSnapshot: string;
alternateScreen: boolean;
perCharacterInput?: boolean;
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
kittyKeyboardProtocolEnabled?: boolean;
},
@@ -1,108 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { JSDOM } from "jsdom";
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import type { Host } from "../../domain/models";
import { STORAGE_KEY_HOSTS } from "../../infrastructure/config/storageKeys";
// Exercise actual vault save/load/JSON import and sync boundaries, including
// the asynchronous encrypted-field adapter (without a native safeStorage key).
test("host input choices survive save, restart, duplicate, backup and cloud sync", async () => {
const dom = new JSDOM("", { url: "http://localhost" });
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import("./useVaultState");
const { buildSyncPayload, applySyncPayload, buildLocalVaultPayload, applyLocalVaultPayload } = await import("../syncPayload");
const { buildVaultHostFromDraft } = await import("../../domain/vaultHostCreate");
const legacy: Host = { id: "legacy", label: "Legacy", hostname: "localhost", username: "agent", protocol: "ssh", tags: [], os: "linux" };
dom.window.localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify([legacy]));
let state: ReturnType<typeof useVaultState>;
let renderer: ReactTestRenderer | undefined;
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
const mount = async () => {
await act(async () => { renderer = create(React.createElement(Probe)); });
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
assert.equal(state!.isInitialized, true);
await act(async () => { await state!.readPersistedHosts(); });
};
const choices = () => state!.hosts.map(host => [host.id, host.sshInputCompatibility]);
try {
await mount();
assert.deepEqual(choices(), [["legacy", true]]);
const created = buildVaultHostFromDraft({ hostname: "new.example.test", username: "agent" });
if (created.ok === false) throw Error(created.error);
assert.equal(created.ok, true);
const standard = { ...created.host, id: "standard" };
await act(async () => { await state!.updateHosts([...state!.hosts, standard]); });
assert.deepEqual(choices(), [["legacy", true], ["standard", false]]);
const duplicate = { ...state!.hosts[0], id: "copy", label: "Legacy copy" };
await act(async () => { await state!.updateHosts([...state!.hosts, duplicate]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["legacy", true], ["standard", false], ["copy", true]]);
// A later deliberate false must survive migration, including another boot.
await act(async () => { await state!.updateHosts(state!.hosts.map(host => host.id === "legacy" ? { ...host, sshInputCompatibility: false } : host)); });
const expected = [["legacy", false], ["standard", false], ["copy", true]];
const exported = JSON.stringify(state!.exportData());
await act(async () => { await state!.updateHosts([]); await state!.importDataFromString(exported); });
assert.deepEqual(choices(), expected);
// Use the real sync payload builder and apply importer, not JSON alone.
const cloud = JSON.parse(JSON.stringify(buildSyncPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
const backup = JSON.parse(JSON.stringify(buildLocalVaultPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
await act(async () => { await state!.updateHosts([]); await applyLocalVaultPayload(backup, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), expected);
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), expected);
assert.deepEqual((await state!.readPersistedHosts()).map(host => [host.id, host.sshInputCompatibility]), expected);
// Older cloud snapshots enter through the same per-record migration.
cloud.hosts = [{ ...legacy, id: "cloud-legacy" }];
await act(async () => { await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
assert.deepEqual(choices(), [["cloud-legacy", true]]);
await act(async () => { await state!.updateHosts(state!.hosts.map(host => ({ ...host, sshInputCompatibility: false }))); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false]]);
// These drafts enter HostDetailsPanel as initialData, then the real vault
// save/normalization path. They must not be mistaken for legacy records.
const { buildSshDeepLinkHostDraft, parseSshDeepLink } = await import("../../domain/sshDeepLink");
const { normalizePluginImporterRecords } = await import("../../domain/pluginImporter");
const linkDraft = buildSshDeepLinkHostDraft(parseSshDeepLink("ssh://agent@new.test")!, { id: "new-link", now: 1 });
const imported = normalizePluginImporterRecords([undefined, false, true].map(choice => ({
type: "draft" as const, draft: { kind: "host" as const, value: {
hostname: `import-${choice}.test`, protocol: "ssh",
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
} },
})));
assert.deepEqual(imported.errors, []);
const explicitCopy = { ...imported.hosts[2], id: "copy-explicit-input" };
const drafts = [linkDraft, ...imported.hosts, explicitCopy];
const draftChoices = drafts.map(host => [host.id, host.sshInputCompatibility]);
assert.deepEqual(drafts.map(host => host.sshInputCompatibility), [false, false, false, true, true]);
await act(async () => { await state!.updateHosts([...state!.hosts, ...drafts]); });
await act(async () => { renderer!.unmount(); });
await mount();
assert.deepEqual(choices(), [["cloud-legacy", false], ...draftChoices]);
} finally {
await act(async () => { renderer?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
-1
View File
@@ -1570,7 +1570,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
group: "",
tags: [],
protocol: "ssh",
sshInputCompatibility: false,
order: getNextVaultOrder(hosts),
};
-11
View File
@@ -1,14 +1,3 @@
!macro customInit
; #3946: external MCP hosts launch Netcatty.exe with ELECTRON_RUN_AS_NODE=1
; via netcatty-external-mcp.cmd. Those processes can outlive the host UI as
; orphans and keep $INSTDIR locked, so the in-app updater hangs (AppHangB1).
; Force-kill leftover Netcatty.exe before files are replaced. Do not pass /T:
; electron-updater spawns this installer as a child of the UI Netcatty.exe,
; and /T would terminate the installer itself. Ignore a missing process.
nsExec::ExecToLog "taskkill /F /IM Netcatty.exe"
Pop $0
!macroend
!macro customInstall
; Open a selected folder in a new Netcatty local terminal.
; Args go after `--` so Electron/Chromium does not swallow them. Use
@@ -503,19 +503,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
enabled={!!form.showLineTimestamps}
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
/>
{(form.protocol === undefined || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<>
<ToggleRow
label={t("hostDetails.sshInputCompatibility")}
hint={t("hostDetails.sshInputCompatibility.desc")}
enabled={form.sshInputCompatibility ?? true}
onToggle={() => update("sshInputCompatibility", !(form.sshInputCompatibility ?? true))}
/>
<p className="text-xs text-muted-foreground">
{t("hostDetails.sshInputCompatibility.reconnect")}
</p>
</>
)}
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
<Select
value={form.backspaceBehavior ?? "default"}
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { compile } from '@tailwindcss/node';
import postcss from 'postcss';
import HostDetailsPanel from './HostDetailsPanel';
import { Card } from './ui/card';
import { TooltipProvider } from './ui/tooltip';
import { PluginPermissionPanel } from './settings/tabs/PluginPermissionPanel';
import { I18nProvider } from '../application/i18n/I18nProvider';
test('real Host form cards, inputs and Save consume scoped security aliases without freezing an ordinary card', async () => {
const markup = renderToStaticMarkup(<I18nProvider locale="en"><TooltipProvider>
<HostDetailsPanel availableKeys={[]} identities={[]} groups={[]} terminalThemeId="default" terminalFontSize={14} onSave={() => assert.fail('test never saves a host')} onCancel={() => {}} />
<PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => {}} />
<Card id="ordinary-card">Outside the security surface</Card>
</TooltipProvider></I18nProvider>);
const dom = new JSDOM(`<!doctype html><html data-plugin-theme-active><body>${markup}</body></html>`);
try {
const panel = dom.window.document.querySelector('[data-section="host-details-panel"]')!;
assert.ok(panel, 'exercise the actual HostDetailsPanel and AsidePanel');
assert.ok(panel.querySelector('.bg-card'), 'General/Address/Credentials use real Card components');
assert.ok(panel.querySelector('input.bg-background'), 'exercise actual Input utilities');
assert.ok(panel.querySelector('button.bg-primary'), 'exercise the actual Save button');
const permissions = dom.window.document.querySelector('[data-host-security-surface]')!;
assert.ok(permissions.querySelector('[role="status"]'), 'exercise the real grant-panel security root without loading or changing any grants');
const source = await readFile(new URL('../index.css', import.meta.url), 'utf8');
const compiler = await compile(source, {base: path.resolve('.'), onDependency() {}});
const classes = [...new Set([...dom.window.document.querySelectorAll('[class]')].flatMap(el => [...el.classList]))];
const css = compiler.build(classes);
const parsed = postcss.parse(css);
const rules: postcss.Rule[] = [];
parsed.walkRules(rule => { rules.push(rule); });
const rule = rules.find(node => node.selector.startsWith(':root[data-plugin-theme-active]'));
assert.ok(rule && rule.type === 'rule');
const matches = [...dom.window.document.querySelectorAll(rule.selector)];
assert.ok(matches.includes(panel));
assert.ok(matches.includes(permissions));
assert.ok(!matches.includes(dom.window.document.getElementById('ordinary-card')!), 'security selector stays within the existing marked surfaces');
const declarations = new Map(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node]));
for (const name of ['background','foreground','card','card-foreground','popover','popover-foreground','primary','primary-foreground','secondary','secondary-foreground','muted','muted-foreground','accent','accent-foreground','destructive','destructive-foreground','border','input','ring']) {
const declaration = declarations.get(`--color-${name}`);
assert.equal(declaration?.value, `hsl(var(--host-security-${name}))`, `Tailwind alias ${name} cannot inherit the plugin value`);
assert.equal(declaration.important, true);
}
for (const name of ['card','background','primary']) {
const utility = rules.find(node => node.selector === `.bg-${name}`);
assert.ok(utility && utility.type === 'rule');
assert.ok(utility.nodes.some(node => node.type === 'decl' && node.prop === 'background-color' && node.value === `var(--color-${name})`), 'verify what the actual compiled utility consumes');
}
const mutedText = rules.find(node => node.selector === '.text-muted-foreground');
assert.ok(mutedText?.nodes.some(node => node.type === 'decl' && node.prop === 'color' && node.value === 'var(--color-muted-foreground)'));
} finally { dom.window.close(); }
});
-1
View File
@@ -182,7 +182,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
os: "linux",
authMethod: undefined,
authPolicyVersion: 1,
sshInputCompatibility: false,
charset: groupDefaults?.charset ? undefined : "UTF-8",
distroMode: "auto",
createdAt: Date.now(),
+1
View File
@@ -882,6 +882,7 @@ echo $3 >> "$FILE"`);
{/* Slide-out Panel */}
{panel.type !== "closed" && (
<AsidePanel
dataSection="keychain-security-panel"
open={true}
onClose={closePanel}
title={panelTitle}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { EventEmitter } from 'node:events';
import { JSDOM } from 'jsdom';
test('host dialog drives choice/input/copy and the two-phase clipboard share using real service action tokens', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { PluginHostInteractionHost } = await import('./plugins/PluginHostInteractionHost');
const { PluginHostInteractionService, assertInteractionRequest } = await import('../electron/plugins/hostInteractionService.cjs');
let listener: ((event: unknown) => void) | null = null;
let reads = 0;
const writes: string[] = [];
const audits: unknown[] = [];
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send(_channel: string, payload: unknown) { listener?.(payload); } });
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
const service = new PluginHostInteractionService({
getWindow: () => owner, isLocked: () => false,
permissionEngine: { onDidRevoke() { return { dispose() {} }; } },
database: { recordSecurityAudit(...args: unknown[]) { audits.push(args); } },
quotaManager: { chargeBytes() {} },
clipboard: { readText() { reads++; return 'synthetic private text'; }, writeText(value: string) { writes.push(value); } },
});
Object.assign(dom.window, { netcatty: {
onPluginInteraction(callback: (event: unknown) => void) { listener = callback; return () => { listener = null; }; },
async setPluginInteractionReady(ready: boolean) { return service.setReady(sender, ready); },
async respondPluginInteraction(response: unknown) { return service.respond(sender, response); },
} });
const context = { pluginId: 'com.example.synthetic', pluginVersion: '1.0.0', runtimeId: 'runtime-synthetic', signal: new AbortController().signal, async assertActive() {} };
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
const click = async (element: HTMLElement) => { assert.ok(element); await act(async () => { element.click(); await settle(); }); };
const begin = async (params: unknown) => {
let promise!: Promise<unknown>;
await act(async () => { promise = service.request(assertInteractionRequest(params), context); void promise.catch(() => {}); await settle(); });
return { promise };
};
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginHostInteractionHost /></I18nProvider>));
const choice = await begin({ kind: 'quickPick', title: 'Synthetic choice', items: [{ id: 'compose', label: 'Compose' }] });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /com.example.synthetic/);
assert.equal(button('Continue').disabled, true);
await click(document.querySelector<HTMLInputElement>('input[type="radio"]')!);
await click(button('Continue'));
assert.deepEqual(await choice.promise, { cancelled: false, value: 'compose' });
await assert.rejects(begin({ kind: 'input', title: 'Invalid hidden newline', initialValue: 'first\nsecond' }), /invalid/);
assert.equal(document.querySelector('[role="dialog"]'), null, 'invalid initial text never creates a misleading single-line dialog');
const input = await begin({ kind: 'input', title: 'Synthetic input', initialValue: 'synthetic output' });
assert.equal(document.querySelector<HTMLInputElement>('input')?.value, 'synthetic output');
await click(button('Continue'));
assert.deepEqual(await input.promise, { cancelled: false, value: 'synthetic output' });
const copy = await begin({ kind: 'clipboard.write', title: 'Copy result', text: 'synthetic output' });
assert.deepEqual(writes, []);
assert.equal(document.querySelector('textarea')?.value, 'synthetic output');
await click(button('Copy to clipboard'));
assert.deepEqual(await copy.promise, { cancelled: false, value: true });
assert.deepEqual(writes, ['synthetic output']);
const share = await begin({ kind: 'clipboard.read', title: 'Share clipboard' });
assert.equal(reads, 0);
assert.equal(document.querySelector('textarea'), null);
await click(button('Preview clipboard'));
assert.equal(reads, 1);
assert.equal(document.querySelector('textarea')?.value, 'synthetic private text');
await click(button('Share with plugin'));
assert.deepEqual(await share.promise, { cancelled: false, value: 'synthetic private text' });
assert.equal(JSON.stringify(audits).includes('synthetic private text'), false);
const cancelled = await begin({ kind: 'clipboard.write', title: 'Cancelled copy', text: 'must not copy' });
await click(button('Cancel'));
assert.deepEqual(await cancelled.promise, { cancelled: true });
assert.deepEqual(writes, ['synthetic output']);
assert.equal(service.pending.size, 0);
} finally {
await act(async () => { root.unmount(); await settle(); }); service.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
+128
View File
@@ -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); }
}
});
+101
View File
@@ -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); }
}
});
@@ -151,3 +151,24 @@ test('plugin palette items keep repeated placements independently addressable',
commandId: 'com.example.palette.run',
}]);
});
test('packaged Starter contributions are discoverable through the real palette builder', async (context) => {
const { createRequire } = await import('node:module');
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const require = createRequire(import.meta.url);
const { PluginDatabase } = require('../electron/plugins/database.cjs');
const { PluginContributionService } = require('../electron/plugins/contributionService.cjs');
const manifest = JSON.parse(readFileSync(new URL('../examples/plugins/starter-netcatty/netcatty.plugin.json', import.meta.url), 'utf8'));
const root = mkdtempSync(join(tmpdir(), 'starter-palette-'));
const database = new PluginDatabase(join(root, 'plugins.sqlite'));
context.after(() => { database.close(); rmSync(root, { recursive: true, force: true }); });
database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: 'a'.repeat(64), packageRelativePath: 'starter' }, { enable: true });
const service = new PluginContributionService({ database, runtimeSupervisor: {}, secretStore: {}, getLocale: () => 'en' });
const items = buildPluginPaletteItems(service.snapshot().plugins, 'Starter');
assert.ok(items.some((item) => item.type === 'plugin-command' && item.commandId === 'com.netcatty.starter.greet'));
assert.ok(items.some((item) => item.type === 'plugin-view' && item.id === 'com.netcatty.starter.panel'));
database.setEnabled(manifest.id, false);
assert.equal(buildPluginPaletteItems(service.snapshot().plugins, 'Starter').length, 0);
});
+6 -1
View File
@@ -1,3 +1,5 @@
import { useAppThemeStyle } from "../application/app/useAppThemeStyle";
import { selectPluginThemeTokens } from "../application/state/pluginContributionEnvironment";
/**
* Settings Page - Standalone settings window content
* This component is rendered in a separate Electron window
@@ -322,6 +324,9 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
};
const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLockState }> = ({ settings, appLock }) => {
// This publisher also runs in detached Settings: never broadcast a local preview.
const committedAppThemeStyle = useAppThemeStyle(false);
const pluginThemeTokens = useMemo(() => selectPluginThemeTokens(committedAppThemeStyle as Record<string, unknown>), [committedAppThemeStyle]);
const { t } = useI18n();
const { request, clearFocus, openSearch } = useSettingsFocus();
const { notifyRendererReady, closeSettingsWindow, onWindowCommandCloseRequested } = useWindowControls();
@@ -715,7 +720,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</Tabs>
</div>
<ExternalMcpApprovalsHost />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} />
<PluginContributionHost locale={settings.uiLanguage} theme={settings.resolvedTheme} themeTokens={pluginThemeTokens} />
</>
);
};
+1 -11
View File
@@ -158,8 +158,6 @@ import {
} from "./terminal/runtime/createTerminalSessionStarters";
import {
createXTermRuntime,
getTerminalPerCharacterInput,
restoreTerminalPerCharacterInput,
resetKittyKeyboardModeStateForSession,
type XTermRuntime,
} from "./terminal/runtime/createXTermRuntime";
@@ -1685,8 +1683,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
passwordPromptActiveRef.current,
knownCwdRef.current ?? null,
terminalTitleRef.current ?? null,
xtermRuntimeRef.current?.getPerCharacterInput()
?? getTerminalPerCharacterInput(terminalSettingsRef),
);
}));
}
@@ -1708,10 +1704,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
throw new Error("Terminal output did not settle before applying the snapshot");
}
}
if (typeof payload.perCharacterInput === "boolean") {
restoreTerminalPerCharacterInput(terminalSettingsRef, payload.perCharacterInput);
xtermRuntimeRef.current?.restorePerCharacterInput(payload.perCharacterInput);
}
if (typeof payload.kittyKeyboardProtocolEnabled === "boolean") {
xtermRuntimeRef.current?.setKittyKeyboardProtocolEnabled(
payload.kittyKeyboardProtocolEnabled,
@@ -1881,8 +1873,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
snap,
{
...finalContext,
perCharacterInput: xtermRuntimeRef.current?.getPerCharacterInput()
?? getTerminalPerCharacterInput(terminalSettingsRef),
kittyKeyboardModeState: xtermRuntimeRef.current?.getKittyKeyboardModeState(),
kittyKeyboardProtocolEnabled:
xtermRuntimeRef.current?.getKittyKeyboardProtocolEnabled(),
@@ -4073,7 +4063,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return;
}
finishReconnectPreparation();
xtermRuntimeRef.current?.resetConnectionInputState(host);
xtermRuntimeRef.current?.resetKittyConnectionInputState();
resetKittyKeyboardModeStateForSession(terminalSettingsRef);
if (effectiveTerminalProtocol.startsWith("plugin:")) {
sessionStarters.startPluginConnection(term);
+14
View File
@@ -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,
+6 -2
View File
@@ -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,
+26
View File
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import { Package } from 'lucide-react';
import { usePluginBrandIcon } from '../../application/state/usePluginBrandIcon';
import { PluginContributionIcon } from './PluginContributionIcon';
import { resolveCatalogBrandIcon } from './catalogBrandIcons';
export function PluginBrandIcon({ plugin, entry, size = 32, className = '' }: {
plugin?: NetcattyInstalledPlugin;
entry?: NetcattyPluginCatalogEntry;
size?: number;
className?: string;
}) {
const manifest = plugin?.manifest ?? entry?.manifest;
const icon = manifest?.icon;
const key = JSON.stringify([plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, icon]);
const loaded = usePluginBrandIcon(plugin);
const [failed, setFailed] = useState<string | null>(null);
const raster = plugin ? loaded : entry ? resolveCatalogBrandIcon(entry) : null;
const imageKey = JSON.stringify([key, raster]);
if (icon?.kind === 'theme') return <PluginContributionIcon icon={icon} size={size} className={className} />;
if (!raster || failed === imageKey) return <Package size={size} className={className} aria-hidden="true" data-plugin-brand="fallback" />;
return <span className={`inline-flex shrink-0 ${className}`} style={{ width: size, height: size }} aria-hidden="true" data-plugin-brand="package">
<img src={raster.light} alt="" width={size} height={size} className={raster.dark ? 'h-full w-full object-contain dark:hidden' : 'h-full w-full object-contain'} onError={() => setFailed(imageKey)} />
{raster.dark && <img src={raster.dark} alt="" width={size} height={size} className="hidden h-full w-full object-contain dark:block" onError={() => setFailed(imageKey)} />}
</span>;
}
@@ -60,7 +60,7 @@ export function PluginContributionIcon({
const packageIcon = usePluginContributionIcon(pluginId, icon);
if (icon?.kind === 'theme') {
const Icon = THEME_ICONS[icon.name] ?? Puzzle;
const Icon = Object.hasOwn(THEME_ICONS, icon.name) ? THEME_ICONS[icon.name] : Puzzle;
return <Icon size={size} className={className} aria-hidden="true" data-plugin-icon-kind="theme" />;
}
if (packageIcon) {
@@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { PLUGIN_HOST_INTERACTION_LIMITS } from '@netcatty/plugin-contract';
import { useI18n } from '../../application/i18n/I18nProvider';
import { usePluginHostInteractions } from '../../application/state/usePluginHostInteractions';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function PluginHostInteractionHost() {
const state = usePluginHostInteractions();
if (!state.prompt) return null;
return <HostInteractionDialog key={state.prompt.requestId} state={state} prompt={state.prompt} />;
}
function HostInteractionDialog({ state, prompt }: {
state: ReturnType<typeof usePluginHostInteractions>;
prompt: NonNullable<ReturnType<typeof usePluginHostInteractions>['prompt']>;
}) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const request = prompt.request;
const previewClipboard = request.kind === 'clipboard.read' && prompt.phase === 'request';
const textBytes = new TextEncoder().encode(state.value).length;
const canSubmit = request.kind === 'quickPick' ? request.items.some((item) => item.id === state.value) : textBytes <= PLUGIN_HOST_INTERACTION_LIMITS.textBytes;
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
<DialogContent className="sm:max-w-[520px]" hideCloseButton>
<DialogHeader>
<DialogTitle>{t('plugins.interaction.title')}</DialogTitle>
<DialogDescription className="break-all">{prompt.pluginId} · {prompt.pluginVersion}</DialogDescription>
</DialogHeader>
<p className="break-words text-sm font-medium">{request.title}</p>
<p className="text-xs text-muted-foreground">{t('plugins.interaction.sharing')}</p>
{request.kind === 'input' && <Input aria-label={t('plugins.interaction.input')} value={state.value} maxLength={PLUGIN_HOST_INTERACTION_LIMITS.textBytes} autoComplete="off" autoFocus disabled={state.busy} onChange={(event) => state.setValue(event.target.value)} />}
{request.kind === 'quickPick' && <>
<Input aria-label={t('plugins.interaction.filter')} value={search} onChange={(event) => setSearch(event.target.value)} autoFocus disabled={state.busy} />
<div className="max-h-64 space-y-2 overflow-y-auto" role="radiogroup" aria-label={request.title}>
{request.items.filter((item) => `${item.label} ${item.description ?? ''}`.toLocaleLowerCase().includes(search.toLocaleLowerCase())).map((item) => <label key={item.id} className="flex items-start gap-3 rounded-md border p-3">
<input type="radio" name="plugin-host-choice" value={item.id} checked={state.value === item.id} disabled={state.busy} onChange={() => state.setValue(item.id)} />
<span className="min-w-0 break-words text-sm">{item.label}{item.description && <span className="block text-xs text-muted-foreground">{item.description}</span>}</span>
</label>)}
</div>
</>}
{previewClipboard && <p className="text-sm">{t('plugins.interaction.previewNote')}</p>}
{(request.kind === 'clipboard.write' || prompt.phase === 'preview') && <textarea aria-label={t('plugins.interaction.preview')} readOnly className="min-h-28 max-h-64 w-full resize-none rounded-md border bg-muted/30 p-3 font-mono text-xs" value={request.kind === 'clipboard.write' ? request.text : prompt.previewText ?? ''} />}
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.interaction.failed')}</p>}
<DialogFooter>
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
<Button disabled={state.busy || !canSubmit} onClick={() => void state.respond(previewClipboard ? 'preview' : 'submit')}>
{t(previewClipboard ? 'plugins.interaction.previewAction' : request.kind === 'clipboard.read' ? 'plugins.interaction.share' : request.kind === 'clipboard.write' ? 'plugins.interaction.copy' : 'common.continue')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
}
@@ -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>;
}
+44
View File
@@ -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>;
}
@@ -0,0 +1,17 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.2",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
},
"light": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAADMElEQVR4nO2d200cQRAAmwuAGJAcF3E4BsfhuCwRAwEYf9lGyz5mdqZf01WfSNydqJreYbUMIgAAAFCRJ8s3e3v//WH5fpl5eX6YuFF9E4TPQysIlRdFvB6zQ5j6Yoi3Y1YIU14E8X6MhvAY/QDI92X05z8UAPJjMOLh1vhAfFx6LwndEwD5sen10xUA8nPQ42l4Ewi5aQ6A1Z+LVl9NASA/Jy3eLgNAfm6u/LEHKM5pAKz+NTjzyAQozmEArP61OPLJBCjObgCs/jXZ88oEKA4BFIcAivMlAK7/a7P1ywQoDgEUhwCKQwDFIYDiEEBxCKA4YQL49v2n90coSYgA/sonAnvcA9hKJwJbXAM4kk0EdrgFcCWZCGxwCaBVLhHoYx5Ar1Qi0MU0gLsyiUAP0wB+/Xi9/b1EoIP5JYAIYuGyCSSCOLj9GkgEMXC9EUQE/rjfCiYCX9wDECECT0IEIEIEXoQJQIQIPAgVgAgRWBMuABEisCRkACJEYEXYAESIwILQAYgQgTbhAxAhAk1SBCBCBFqkCUCECDRIFYDI/QhG4lmZdAGI9MtE/jEpAxBpl4r8c9IGwKPlc0gZAI+WzyNdADxaPpdUAYxIZC+wT5oAkK9DigCQr0f4AJCvS+gAkK9P2ACQb0PIAJBvR7gAkG9LqACQb0+YAJDvQ4gAkO+HewDI9yXkOYEtIH8OYc8JPAP58wh9TuAeyJ9L+HMCP4P8+aQ4J1AE+VqkOCcQ+Xo8bb9g8Y8jeyaBhXzvx8WsA395fvzzHvqcQFa+PmHPCUS+DSHPCUS+He63greykW+LewAi/6Uj354QAYgg34swAYAPBFAclxtB4Iv7jSCIAwEUhwCKQwDFIYDiEEBxCKA4BFCcLwF8vkkA67H1ywQoDgEUhwCKsxsA+4A12fPKBCjOYQBMgbU48skEKM5pAEyBNTjzyAQozmUATIHcXPlrmgBEkJMWb82XACLIRasv9gDF6QqAKZCDHk/dE4AIYtPrZ0gmf0QSh7sLc2gPwDSIwYiH4U0gEfgy+vOfKo9Lgh2zFp7K6iUEPWZPXNXxTQjz0LrUml6/CaId9lYAAKDMHwpGNG+oRdPRAAAAAElFTkSuQmCC",
"lightSha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
"dark": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAADMElEQVR4nO2cO07EMBQAH4iSk1FyBmokToJEzRkoOdn2UCHQko8d+/3yZvrdrJjxi4kiiwAAAEBFbiwvdv/w/GV5vcxcPt9M3KheBOHz0ApC5UsRr8fsEKZ+GeLtmBXClC9BvB+jIdyO/gDk+zL69x8KAPkxGPFwaHwgPi69t4TuCYD82PT66QoA+Tno8TS8CYTcNAfA6s9Fq6+mAJCfkxZvuwEgPzd7/tgDFGczAFb/OdjyyAQozmoArP5zseaTCVCcxQBY/edkySsToDgEUBwCKM6/ALj/n5trv0yA4hBAcQigOARQHAIoDgEUhwCKEyaAj/dX759QkhAB/MgnAnvcA7iWTgS2uAawJpsI7HALYE8yEdjgEkCrXCLQxzyAXqlEoItpAEdlEoEepgE8Pr0c/iwR6GB+CyCCWLhsAokgDm7/BhJBDFwfBBGBP+6PgonAF/cARIjAkxABiBCBF2ECECECD0IFIEIE1oQLQIQILAkZgAgRWBE2ABEisCB0ACJEoE34AESIQJMUAYgQgRZpAhAhAg1SBSByPIKReM5MugBE+mUif52UAYi0S0X+NmkD4NXyOaQMgFfL55EuAF4tn0uqAEYkshdYJk0AyNchRQDI1yN8AMjXJXQAyNcnbADItyFkAMi3I1wAyLclVADItydMAMj3IUQAyPfDPQDk+xLynMAWkD+HsOcEboH8eYQ+J3AJ5M8l/DmBf0H+fFKcEyiCfC1SnBOIfD3urC/4+PTSNQks5Hu/LuYZeOhzAln5+oQ9JxD5NoQ8JxD5drg/Cr6WjXxb3AMQ+ZWOfHtCBCCCfC/CBAA+EEBxzB8ERaTy7YcJUBwCKA4BFIcAikMAxSGA4hBAcQigOP8CuHy+3Xj8ELDh2i8ToDgEUBwCKM5iAOwDzsmSVyZAcVYDYAqcizWfTIDibAbAFDgHWx6ZAMXZDYApkJs9f00TgAhy0uKt+RZABLlo9cUeoDhdATAFctDjqXsCEEFsev0Mybx/eP4a+TzM4+jCHNoDMA1iMOJheBNIBL6M/v2nyuOWYMeshaeyeglBj9kTV3V8E8I8tG61pvdvgmiHvRUAACjzDQ0NL/vgx29SAAAAAElFTkSuQmCC",
"darkSha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe"
}
]
+9
View File
@@ -0,0 +1,9 @@
import previews from './catalog-brand-icons.json';
/** Only bundled reviewed assets; catalog data never becomes an image URL. */
export function resolveCatalogBrandIcon(entry: NetcattyPluginCatalogEntry): { light: string; dark?: string } | null {
const preview = previews.find((item) => item.id === entry.id && item.version === entry.version
&& item.repository === entry.repository && item.commit === entry.commit
&& JSON.stringify(item.icon) === JSON.stringify(entry.manifest.icon));
return preview ? { light: preview.light, dark: preview.dark } : null;
}
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { inspectPluginBrandIcon } from '@netcatty/plugin-contract';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { resolveCatalogBrandIcon } from '../../plugins/catalogBrandIcons';
import previews from '../../plugins/catalog-brand-icons.json';
const preview = previews[0];
const entry = { ...preview, manifest: { icon: preview.icon } } as unknown as NetcattyPluginCatalogEntry;
test('reviewed catalog brand previews bind version, source and declaration, never arbitrary URLs', () => {
assert.ok(resolveCatalogBrandIcon(entry));
const catalog = JSON.parse(readFileSync(new URL('../../../electron/plugins/catalog.json', import.meta.url), 'utf8')) as NetcattyPluginCatalogEntry[];
assert.ok(resolveCatalogBrandIcon(catalog.find((item) => item.id === preview.id)!));
for (const replacement of [{ version: '9.0.0' }, { commit: '0'.repeat(40) }, { repository: 'untrusted/plugin' }, { manifest: { icon: { kind: 'package', light: 'https://attacker.invalid/a.png' } } }]) {
assert.equal(resolveCatalogBrandIcon({ ...entry, ...replacement } as NetcattyPluginCatalogEntry), null);
}
for (const theme of ['light', 'dark'] as const) {
const bytes = Buffer.from(preview[theme].split(',')[1], 'base64');
assert.equal(createHash('sha256').update(bytes).digest('hex'), preview[`${theme}Sha256`]);
assert.equal(inspectPluginBrandIcon(bytes, preview.icon[theme]).width, 128);
assert.deepEqual(bytes, readFileSync(new URL(`../../../examples/plugins/starter-netcatty/${preview.icon[theme]}`, import.meta.url)));
}
const html = renderToStaticMarkup(<PluginBrandIcon entry={entry} />);
assert.match(html, /data-plugin-brand="package"/);
assert.match(html, /dark:hidden/);
assert.match(html, /dark:block/);
assert.doesNotMatch(html, /src="https?:/);
assert.match(renderToStaticMarkup(<PluginBrandIcon />), /data-plugin-brand="fallback"/);
});
test('installed brand uses metadata only, rejects stale responses, and falls back after image errors', async () => {
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const requests: Array<(v: { light: string }) => void> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { getPluginBrandIcon: () => new Promise((resolve) => requests.push(resolve)) } } });
const plugin = { id: 'com.netcatty.starter', enabled: false, activeVersion: '1.0.0', archiveSha256: 'a', runtime: {}, manifest: { icon: preview.icon } } as NetcattyInstalledPlugin;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(<PluginBrandIcon plugin={plugin} />); });
assert.equal(requests.length, 1);
await act(async () => { renderer!.update(<PluginBrandIcon plugin={{ ...plugin, archiveSha256: 'b' }} />); });
await act(async () => requests[0]({ light: preview.light }));
assert.equal(renderer!.root.findAllByType('img').length, 0);
await act(async () => requests[1]({ light: preview.light }));
assert.equal(renderer!.root.findAllByType('img').length, 1);
await act(async () => renderer!.root.findByType('img').props.onError());
assert.equal(renderer!.root.findAllByType('img').length, 0);
await act(async () => { renderer!.update(<PluginBrandIcon />); });
assert.equal(renderer!.root.findAllByType('img').length, 0);
} finally {
await act(async () => renderer?.unmount());
if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow); else Reflect.deleteProperty(globalThis, 'window');
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});
test('disabled brand theme names cannot resolve Object prototype members', () => {
for (const name of ['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty']) {
const plugin = { id: 'com.test.theme', enabled: false, activeVersion: '1.0.0', runtime: {}, manifest: { icon: { kind: 'theme', name } } } as NetcattyInstalledPlugin;
const html = renderToStaticMarkup(<PluginBrandIcon plugin={plugin} />);
assert.match(html, /lucide-puzzle/);
assert.doesNotMatch(html, /<img/);
}
});
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('information previews on hover/focus, pins on click and Escape returns focus without closing the manager', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act, useState } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginInfo } = await import('./PluginInfo');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const root = createRoot(document.getElementById('root')!);
const settle = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog><PluginInfo label="Plugin help">Packages remain disabled until reviewed.</PluginInfo><button data-other>Other control</button></PluginManagementDialog></I18nProvider>));
await act(async () => { document.querySelector<HTMLButtonElement>('button')!.click(); await settle(); });
const trigger = document.querySelector<HTMLButtonElement>('button[aria-label="Plugin help"]')!;
const other = document.querySelector<HTMLButtonElement>('[data-other]')!;
assert.ok(trigger); assert.equal(trigger.getAttribute('title'), null, 'not a native title-only hint');
await act(async () => { other.focus(); await settle(); });
const hover = new MouseEvent('pointermove', { bubbles: true });
Object.defineProperty(hover, 'pointerType', { value: 'mouse' });
await act(async () => { trigger.dispatchEvent(hover); await settle(260); });
assert.match(document.querySelector('[role="tooltip"]')?.textContent ?? '', /Packages remain disabled/);
await act(async () => { trigger.focus(); await settle(); });
assert.ok(document.querySelector('[role="tooltip"]'));
await act(async () => { trigger.click(); await settle(); });
const pinned = document.querySelector<HTMLElement>('[role="dialog"][aria-label="Plugin help"]');
assert.ok(pinned, 'click exposes a persistent readable popover');
assert.equal(document.querySelector('[role="tooltip"]'), null);
await act(async () => { pinned.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
assert.equal(document.querySelector('[role="dialog"][aria-label="Plugin help"]'), null);
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1, 'Escape closes only the information layer');
assert.equal(document.activeElement, trigger);
assert.equal(Boolean(document.querySelector('[role="tooltip"]')), false, 'Escape does not immediately reopen the focus preview');
await act(async () => { other.focus(); trigger.focus(); await settle(); });
assert.ok(document.querySelector('[role="tooltip"]'), 'a subsequent deliberate focus still previews help');
const { PluginMarketplaceContent } = await import('./PluginMarketplace');
const entry = { id: 'com.example.guide', name: 'Guide', author: 'Example', version: '1.0.0', license: 'MIT', repository: 'example/plugin', commit: 'a'.repeat(40), description: 'Synthetic metadata',
manifest: { id: 'com.example.guide', name: 'Guide', publisher: 'Example', main: { browser: 'index.js' }, engines: { netcatty: '*', api: '0.1.0-internal' } },
} as NetcattyPluginCatalogEntry;
const catalog = { entries: [entry], source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Guide', readmeStatus: 'ready', readmeError: null, error: null, busy: false, retryList() {}, retryReadme() {}, async install() {} } as ReturnType<typeof import('../../../application/state/usePluginCatalog').usePluginCatalog>;
function Browser() {
const [selected, setSelected] = useState<string | null>(null);
const [toolbar, setToolbar] = useState<HTMLDivElement | null>(null);
return <I18nProvider locale="en"><div data-toolbar ref={setToolbar} /><div data-settings-scroll-pane><PluginMarketplaceContent catalog={catalog} installed={[]} selected={selected} onSelect={setSelected} toolbarContainer={toolbar} /></div></I18nProvider>;
}
await act(async () => { root.render(<Browser />); await settle(); });
const pane = document.querySelector<HTMLElement>('[data-settings-scroll-pane]')!;
assert.match(document.querySelector('[data-toolbar]')?.textContent ?? '', /Refresh/);
pane.scrollTop = 450;
await act(async () => { document.querySelector<HTMLButtonElement>('[data-plugin-id]')!.click(); await settle(); });
assert.equal(document.querySelector('.plugin-marketplace-catalog'), null, 'detail replaces the candidate list');
assert.doesNotMatch(document.querySelector('[data-toolbar]')?.textContent ?? '', /Refresh/, 'list refresh stays out of detail');
assert.equal(pane.scrollTop, 0);
assert.equal(document.activeElement?.tagName, 'H4');
assert.equal(document.activeElement?.textContent, 'Guide');
const back = Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.includes('All plugins'))!;
await act(async () => { back.click(); await settle(); });
assert.equal(pane.scrollTop, 450);
assert.equal(document.activeElement?.getAttribute('data-plugin-id'), entry.id);
assert.match(document.querySelector('[data-toolbar]')?.textContent ?? '', /Refresh/);
const { PluginStatus } = await import('./PluginStatus');
function StatusHarness() {
const [offline, setOffline] = useState(true);
const [noVault, setNoVault] = useState(true);
return <I18nProvider locale="en"><PluginStatus noVault={noVault} offlineError={offline ? 'Network unavailable' : null} retry={() => setOffline(false)} /><button onClick={() => setNoVault(false)}>Restore vault</button></I18nProvider>;
}
await act(async () => { root.render(<StatusHarness />); await settle(); });
const status = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 2"]')!;
await act(async () => { status.focus(); await settle(); });
assert.match(document.querySelector('[role="tooltip"]')?.textContent ?? '', /Network unavailable/);
assert.equal(document.querySelector('[role="tooltip"] button'), null, 'hover preview never contains an interactive Retry');
await act(async () => { status.click(); await settle(); });
const retry = document.querySelector<HTMLButtonElement>('[role="dialog"] button')!;
assert.equal(retry.textContent, 'Retry');
assert.equal(status.textContent, '2', 'only the number badge is visible, not a second text label');
assert.equal(document.querySelectorAll('button[aria-label*="Notices:"]').length, 1);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
assert.ok(document.querySelector('[role="dialog"] hr'));
await act(async () => { retry.click(); await settle(); });
assert.ok(document.querySelector('button[aria-label$="Notices: 1"]'));
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /Network unavailable/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
assert.match(document.activeElement?.getAttribute('aria-label') ?? '', /Notices: 1$/);
await act(async () => { Array.from(document.querySelectorAll('button')).find((button) => button.textContent === 'Restore vault')!.click(); await settle(); });
const healthy = document.querySelector<HTMLButtonElement>('button[aria-label="Plugin information"]')!;
assert.ok(healthy, 'one information control remains when healthy');
assert.equal(healthy.querySelector('span[aria-hidden="true"]'), null, 'healthy control has no count badge');
await act(async () => { healthy.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Updates are manual/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
const thirdParty: NetcattyPluginSource = { id: 'third-party:synthetic', kind: 'third-party', name: 'Independent source', origin: 'https://example.com' };
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={{ ...catalog, entries: [], listError: 'ECONNREFUSED', source: 'cached' }} installed={[]} selected={null} onSelect={() => {}} /></I18nProvider>); await settle(); });
const thirdStatus = document.querySelector<HTMLButtonElement>('button[aria-label$="Notices: 1"]')!;
await act(async () => { thirdStatus.focus(); await settle(); });
const thirdPreview = document.querySelector('[role="tooltip"]')?.textContent ?? '';
assert.match(thirdPreview, /provided by the selected third party/);
assert.match(thirdPreview, /If a previously validated catalog exists for this source/);
assert.doesNotMatch(thirdPreview, /bundled examples|Curated open-source|First-party/);
assert.equal(document.querySelector('[role="tooltip"] button'), null);
await act(async () => { thirdStatus.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /ECONNREFUSED/);
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Retry/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /bundled examples|Curated open-source/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
await act(async () => { root.render(<I18nProvider locale="en"><PluginMarketplaceContent marketSource={thirdParty} catalog={catalog} installed={[]} selected={entry.id} onSelect={() => {}} /></I18nProvider>); await settle(); });
const sourceInfo = document.querySelector<HTMLButtonElement>('button[aria-label="Source example"]')!;
await act(async () => { sourceInfo.click(); await settle(); });
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /Source-only entry from this provider/);
assert.doesNotMatch(document.querySelector('[role="dialog"]')?.textContent ?? '', /First-party/);
await act(async () => { document.querySelector('[role="dialog"]')!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
} 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);
}
}
});
+25
View File
@@ -0,0 +1,25 @@
import React, { useRef, useState } from 'react';
import { Info } from 'lucide-react';
import { Button } from '../../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../../ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../ui/tooltip';
/** Hover/focus previews explanatory text; click pins it in the existing accessible popover. */
export function PluginInfo({ label, children, badgeCount, previewContent }: { label: string; children: React.ReactNode; badgeCount?: number; previewContent?: React.ReactNode }) {
const [pinned, setPinned] = useState(false);
const [preview, setPreview] = useState(false);
const suppressFocusPreview = useRef(false);
return <Popover open={pinned} onOpenChange={(open) => { setPinned(open); setPreview(false); }}>
<TooltipProvider delayDuration={200}>
<Tooltip open={!pinned && preview} onOpenChange={(open) => { if (!open || !suppressFocusPreview.current) setPreview(open); }}>
<TooltipTrigger asChild><PopoverTrigger asChild>
<Button type="button" variant="ghost" size="sm" className="relative h-8 w-8 shrink-0 p-0 text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" aria-label={label} onBlur={() => { suppressFocusPreview.current = false; }} onPointerMove={() => { suppressFocusPreview.current = false; }}>
<Info size={15} />{Boolean(badgeCount) && <span aria-hidden="true" className="absolute right-0 top-0 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-muted px-0.5 text-[9px] font-medium leading-none text-foreground">{badgeCount}</span>}
</Button>
</PopoverTrigger></TooltipTrigger>
<TooltipContent className="max-w-xs space-y-2 bg-popover p-3 text-xs leading-5 text-popover-foreground shadow-sm">{previewContent ?? children}</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent onEscapeKeyDown={() => { suppressFocusPreview.current = true; }} aria-label={label} align="end" className="max-w-xs space-y-2 text-xs leading-5">{children}</PopoverContent>
</Popover>;
}
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('installed grid filters, opens one detail and restores search, scroll and card focus without pill navigation', async () => {
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const raf = globalThis.requestAnimationFrame;
const frames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
const selections: unknown[] = []; const toggles: unknown[] = []; const focus: string[] = [];
const pane = { scrollTop: 160 };
const plugins = ['Alpha', 'Beta'].map((name) => ({ id: `com.test.${name.toLowerCase()}`, enabled: false, activeVersion: '1.0.0',
manifest: { name, publisher: 'Example', description: `${name} safely formats synthetic greetings without accessing hosts or secrets.`, license: 'MIT', main: { browser: 'index.js' }, engines: { api: '0.1.0-internal', netcatty: '*' } },
runtime: { lastError: name === 'Beta' ? 'Incompatible version' : null },
})) as NetcattyInstalledPlugin[];
const manager = { available: true, plugins, busy: false, setEnabled: async (...args: unknown[]) => { toggles.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
let renderer!: ReactTestRenderer;
try {
await act(async () => { renderer = create(<I18nProvider locale="en"><PluginPackageManager manager={manager} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>, {
createNodeMock: (element) => element.type === 'section' ? {
closest: () => pane,
querySelector: () => ({ focus: () => focus.push('detail') }),
querySelectorAll: () => plugins.map((plugin) => ({ dataset: { pluginCard: plugin.id }, focus: () => focus.push(plugin.id) })),
} : null,
}); });
const cards = () => renderer.root.findAllByType('button').filter((node) => node.props['data-plugin-card']);
assert.equal(cards().length, 2);
const alphaCard = renderer.root.findAllByType('article')[0];
assert.match(JSON.stringify(renderer.toJSON()), /Alpha safely formats synthetic greetings/);
assert.ok(alphaCard.findAllByType('p').some((node) => node.children.includes('Example')), 'author has its own line');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
assert.ok(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')));
const pill = renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
await act(async () => pill.props.onClick());
assert.deepEqual(toggles, [[plugins[0].id, true]]); assert.deepEqual(selections, []);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'Alpha' } }));
assert.equal(cards().length, 1);
await act(async () => cards()[0].props.onClick()); frames.splice(0).forEach((fn) => fn(0));
assert.deepEqual(selections, [plugins[0].id]); assert.equal(pane.scrollTop, 0); assert.equal(focus.at(-1), 'detail');
assert.equal(renderer.root.findAllByType('input').length, 0);
assert.match(JSON.stringify(renderer.toJSON()), /Declared permissions/);
assert.ok(renderer.root.findByType('article').findAllByType('p').some((node) => node.props.className?.includes('whitespace-pre-wrap')), 'detail shows the complete description');
const back = renderer.root.findAllByType('button').find((node) => node.children.includes('Installed plugins'))!;
await act(async () => back.props.onClick()); frames.splice(0).forEach((fn) => fn(0));
assert.equal(renderer.root.findByType('input').props.value, 'Alpha'); assert.equal(cards().length, 1);
assert.equal(pane.scrollTop, 160); assert.equal(focus.at(-1), plugins[0].id);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'none' } }));
assert.equal(cards().length, 0);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: '' } }));
await act(async () => renderer.update(<I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, statusKnown: false, error: 'synthetic state unavailable' }} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>));
const unknown = renderer.root.findAllByType('button').filter((node) => node.children.includes('Status unknown'));
assert.equal(unknown.length, 2);
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
});
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
// Exercise the real Radix portal/focus lifecycle, rather than a mocked Dialog.
test('plugin manager is single-instance, closes with Escape, restores focus and remounts on reopen', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act, useEffect } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const root = createRoot(document.getElementById('root')!);
let mounts = 0;
let unmounts = 0;
function Content() {
useEffect(() => { mounts++; return () => { unmounts++; }; }, []);
return <input aria-label="Plugin search" defaultValue="" />;
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const click = async (element: HTMLElement) => { await act(async () => { element.click(); await settle(); }); };
try {
await act(async () => { root.render(<I18nProvider locale="en"><PluginManagementDialog><Content /></PluginManagementDialog></I18nProvider>); });
const trigger = document.querySelector<HTMLButtonElement>('button')!;
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(mounts, 0, 'closed settings entry does not mount manager subscriptions');
trigger.focus();
await click(trigger);
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(mounts, 1);
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Plugin search');
assert.equal(document.querySelector('[role="dialog"]')?.contains(document.querySelector('[data-settings-scroll-pane]')), true);
await act(async () => { root.render(<I18nProvider locale="en"><PluginManagementDialog><Content /></PluginManagementDialog></I18nProvider>); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(mounts, 1, 'rerender does not spawn another manager');
const closeButton = document.querySelector<HTMLButtonElement>('[role="dialog"] button[data-dialog-close]:not([aria-hidden])')!;
await act(async () => {
closeButton.focus();
closeButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
});
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Plugin search', 'Tab wraps inside the modal');
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.equal(unmounts, 1);
await click(trigger);
assert.equal(mounts, 2);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button[data-dialog-close]:not([aria-hidden])')!);
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.equal(unmounts, 2);
let handedOff = false;
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog>{(handoff) => <button onClick={() => handoff(() => {
handedOff = true;
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.notEqual(document.body.style.pointerEvents, 'none');
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,38 @@
import React, { useRef, useState } from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
/** Uses the existing renderer and host bridge; opening the manager never starts a plugin. */
export function PluginManagementDialog({ children }: {
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const pendingHandoff = useRef<(() => void) | null>(null);
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('settings.plugins.openManager')}</Button>
</DialogTrigger>
<DialogContent onCloseAutoFocus={(event) => {
const action = pendingHandoff.current;
pendingHandoff.current = null;
if (action) {
event.preventDefault();
// Wait for Radix to release modal pointer/focus ownership before opening
// the existing external plugin view host (including exit animations).
queueMicrotask(action);
}
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<DialogTitle>{t('settings.plugins.title')}</DialogTitle>
</DialogHeader>
<div data-settings-scroll-pane className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 sm:p-6">
{typeof children === 'function' ? children(handoff) : children}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,178 @@
import assert from 'node:assert/strict';
import test, { before, after } from 'node:test';
import { JSDOM } from 'jsdom';
import React, { useState } from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginMarketplaceContent } from './PluginMarketplace';
import type { usePluginCatalog } from '../../../application/state/usePluginCatalog';
const infoDom = new JSDOM('<!doctype html><html><body></body></html>');
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocument = Object.getOwnPropertyDescriptor(globalThis, 'document');
before(() => {
Object.defineProperty(globalThis, 'window', { configurable: true, value: infoDom.window });
Object.defineProperty(globalThis, 'document', { configurable: true, value: infoDom.window.document });
});
after(() => {
infoDom.window.close();
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); else Reflect.deleteProperty(globalThis, 'window');
if (previousDocument) Object.defineProperty(globalThis, 'document', previousDocument); else Reflect.deleteProperty(globalThis, 'document');
});
const entries = ['Alpha', 'Beta'].map((name) => ({
id: `com.example.${name.toLowerCase()}`, name, author: 'Example', version: '1.0.0', license: 'MIT',
description: `${name} local utility`, repository: 'example/plugins', commit: 'a'.repeat(40),
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
})) as NetcattyPluginCatalogEntry[];
const catalog = {
entries, source: 'registry', sourceBinding: null, listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
} as ReturnType<typeof usePluginCatalog>;
test('catalog keeps search during selection/back, switches detail, and displays real installed state', async () => {
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => { callback(0); return 0; };
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer;
function Harness() {
const [selected, setSelected] = useState<string | null>(null);
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected}
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' }, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
}
try {
await act(async () => { renderer = create(<Harness />); });
const cards = () => renderer.root.findAll((node) => node.type === 'button' && Boolean(node.props['data-plugin-id']));
const input = () => renderer.root.findByType('input');
assert.equal(cards().length, 2);
await act(async () => input().props.onChange({ target: { value: 'Alpha' } }));
assert.equal(cards().length, 1);
await act(async () => cards()[0].props.onClick());
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Alpha');
assert.equal(cards().length, 0, 'detail replaces the entire grid at every width');
assert.equal(renderer.root.findAllByType('input').length, 0);
const detailText = JSON.stringify(renderer.toJSON());
assert.match(detailText, /0.9.0/);
assert.match(detailText, /Disabled/);
assert.match(detailText, /Incompatible with this host/);
assert.match(detailText, /Last recorded error/);
assert.equal(renderer.root.findAllByType('details').length, 0, 'permissions are not hidden in a disclosure');
assert.equal(renderer.root.findAll((node) => node.type === 'button' && node.children.includes('Install this version (disabled)')).length, 0, 'source-only cards have no install action');
const back = () => renderer.root.findAllByType('button').find((node) => node.children.includes('All plugins'))!;
await act(async () => back().props.onClick());
assert.equal(renderer.root.findAllByType('article').length, 0);
assert.equal(input().props.value, 'Alpha');
await act(async () => input().props.onChange({ target: { value: '' } }));
await act(async () => cards()[1].props.onClick());
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Beta');
assert.equal(cards().length, 0);
await act(async () => back().props.onClick());
await act(async () => input().props.onChange({ target: { value: 'no match' } }));
assert.equal(cards().length, 0);
assert.equal(input().props.value, 'no match');
await act(async () => renderer.unmount());
} finally {
globalThis.requestAnimationFrame = previousRaf;
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('README failure leaves the permissions and source visible, before the failure message', () => {
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, readmeStatus: 'error', readme: null, readmeError: 'Network unavailable' }}
selected={entries[0].id} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(html, /Compatibility and permissions/);
assert.match(html, /settings.read/);
assert.match(html, /not a grant/);
assert.match(html, /Source example/);
assert.ok(html.indexOf('settings.read') < html.indexOf('Network unavailable'));
assert.match(html, /Retry/);
assert.doesNotMatch(html, /<details/);
});
test('cards use subtle shadows and details have no breakpoint-specific columns or nested scroller', () => {
const css = readFileSync(new URL('./plugin-marketplace.css', import.meta.url), 'utf8');
assert.match(css, /box-shadow: 0 1px 4px/);
assert.match(css, /\.dark \.plugin-card/);
assert.doesNotMatch(css, /@container|248px|max-height|overflow-y/);
});
test('full-page detail starts at the top and back restores the settings scroll and card focus', async () => {
const previousRaf = globalThis.requestAnimationFrame;
const frames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = (callback) => { frames.push(callback); return frames.length; };
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
const pane = { scrollTop: 450 };
let focused = false;
const list = { scrollTop: 0, closest: (selector: string) => selector === '.plugin-marketplace' ? { clientWidth: 1100 } : pane,
querySelectorAll: () => [{ dataset: { pluginId: entries[0].id }, focus: () => { focused = true; } }] };
let renderer!: ReactTestRenderer;
function Harness() {
const [selected, setSelected] = useState<string | null>(null);
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected} installed={[]} /></I18nProvider>;
}
try {
await act(async () => { renderer = create(<Harness />, { createNodeMock: (element) => element.type === 'div' && (element.props as { className?: string }).className?.includes('plugin-marketplace-catalog') ? list : null }); });
const card = renderer.root.findAll((node) => node.type === 'button' && node.props['data-plugin-id'] === entries[0].id)[0];
await act(async () => card.props.onClick());
frames.splice(0).forEach((frame) => frame(0));
assert.equal(pane.scrollTop, 0);
const back = renderer.root.findAllByType('button').find((node) => node.children.includes('All plugins'))!;
await act(async () => back.props.onClick());
frames.splice(0).forEach((frame) => frame(0));
assert.equal(pane.scrollTop, 450);
assert.equal(focused, true);
await act(async () => renderer.unmount());
} finally {
globalThis.requestAnimationFrame = previousRaf;
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('offline catalog warning keeps details visible and disables market installation', () => {
const downloadable = { ...entries[0], artifact: { url: 'https://github.com/example/plugin/releases/download/v1/plugin.ncpkg', sha256: 'a'.repeat(64) } };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, entries: [downloadable], listError: 'Registry offline', source: 'cached' }}
selected={downloadable.id} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(html, /Offline catalog · installation unavailable/);
assert.match(html, /Compatibility and permissions/);
assert.match(html, /<button[^>]*disabled=""[^>]*>Install this version/);
assert.match(html, /Notices: 1/);
});
test('manual refresh remains available after success and is disabled while loading', () => {
const ready = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(ready, />Refresh<\/button>/);
const loading = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, listStatus: 'loading' }} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(loading, /<button[^>]*disabled=""[^>]*>Refresh<\/button>/);
});
test('replacing an installed plugin discloses that its running version will stop beside the install action', () => {
const downloadable = { ...entries[0], artifact: { url: 'https://github.com/example/plugin/releases/download/v1/plugin.ncpkg', sha256: 'a'.repeat(64) } };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, entries: [downloadable] }} selected={downloadable.id} onSelect={() => {}}
installed={[{ id: downloadable.id, activeVersion: '0.9.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin]} /></I18nProvider>);
assert.match(html, /<p[^>]*>This replaces and stops the installed version\./);
assert.ok(html.indexOf('This replaces and stops') < html.indexOf('Install this version (disabled)'));
});
test('a mirror cannot claim another source installation or offer cross-source replacement', () => {
const mirror = { id: 'third-party:mirror', kind: 'third-party' as const, name: 'Netcatty', origin: 'https://mirror.example.com' };
const existing = { id: entries[0].id, activeVersion: '1.0.0', enabled: true, source: { id: 'official', kind: 'official', name: 'Official', origin: 'https://plugins.netcatty.app' } } as NetcattyInstalledPlugin;
const list = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog}
marketSource={mirror} selected={null} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
assert.doesNotMatch(list, />Installed</);
const downloadable = { ...entries[0], artifact: { url: `${mirror.origin}/assets/${'a'.repeat(64)}.ncpkg`, sha256: 'a'.repeat(64) } };
const detail = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, entries: [downloadable] }}
marketSource={mirror} selected={downloadable.id} onSelect={() => {}} installed={[existing]} /></I18nProvider>);
assert.match(detail, /installed from another source/);
assert.match(detail, /https:\/\/plugins.netcatty.app/);
assert.match(detail, /disabled=""[^>]*>Install this version/);
});
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { filterPluginCatalog } from './PluginMarketplace';
test('catalog search matches name, author and description without case sensitivity', () => {
const entries = [{ id: 'a', name: 'Starter', author: 'Netcatty', description: 'Local greeting' }] as NetcattyPluginCatalogEntry[];
for (const query of ['starter', 'NETCATTY', ' greeting ']) assert.equal(filterPluginCatalog(entries, query).length, 1);
assert.equal(filterPluginCatalog(entries, 'unrelated').length, 0);
assert.equal(filterPluginCatalog(entries, '').length, 1);
});
@@ -0,0 +1,122 @@
import { createPortal } from 'react-dom';
import { PluginStatus } from './PluginStatus';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
import { PluginInfo } from './PluginInfo';
import React, { useRef, useState } from 'react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { usePluginCatalog } from '../../../application/state/usePluginCatalog';
import { Input } from '../../ui/input';
import { Button } from '../../ui/button';
import { PluginPackageDetails } from './PluginPackageManager';
import { PluginReadme } from './PluginReadme';
export function PluginMarketplace({ installed = [], toolbarContainer, noVault, source }: { source?: NetcattyPluginSource; noVault?: boolean; installed?: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null }) {
const [selected, setSelected] = useState<string | null>(null);
const catalog = usePluginCatalog(selected, source?.id);
return <PluginMarketplaceContent catalog={catalog} marketSource={source} installed={installed} selected={selected} onSelect={setSelected} toolbarContainer={toolbarContainer} noVault={noVault} />;
}
// The browser owns only presentation state; installation and README policy stay in the existing hook.
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect, toolbarContainer, noVault, marketSource }: {
marketSource?: NetcattyPluginSource;
noVault?: boolean;
toolbarContainer?: HTMLElement | null;
catalog: ReturnType<typeof usePluginCatalog>;
installed: NetcattyInstalledPlugin[];
selected: string | null;
onSelect: (id: string | null) => void;
}) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const [confirmInstall, setConfirmInstall] = useState(false);
const [clearPreviousSource, setClearPreviousSource] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
const detailRef = useRef<HTMLHeadingElement>(null);
const installButtonRef = useRef<HTMLButtonElement>(null);
const lastSelection = useRef<string | null>(null);
const pageScroll = useRef(0);
const entry = catalog.entries.find((item) => item.id === selected);
const filtered = filterPluginCatalog(catalog.entries, search);
const sourceId = marketSource?.id ?? 'official';
const sameSource = (plugin: NetcattyInstalledPlugin) => (plugin.source?.id ?? 'local') === sourceId;
const installedEntry = installed.find((item) => item.id === entry?.id && sameSource(item));
const conflictingInstall = installed.find((item) => item.id === entry?.id && !sameSource(item));
const choose = (id: string) => {
const pane = listRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
pageScroll.current = pane?.scrollTop ?? 0;
lastSelection.current = id;
onSelect(id);
requestAnimationFrame(() => {
if (pane) pane.scrollTop = 0;
detailRef.current?.focus({ preventScroll: true });
});
};
const back = () => {
onSelect(null);
requestAnimationFrame(() => {
if (listRef.current) {
const pane = listRef.current.closest('[data-settings-scroll-pane]');
if (pane) pane.scrollTop = pageScroll.current;
const button = Array.from(listRef.current.querySelectorAll<HTMLButtonElement>('[data-plugin-id]'))
.find((item) => item.dataset.pluginId === lastSelection.current);
button?.focus({ preventScroll: true });
}
});
};
const refresh = !entry && <Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button>;
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
{toolbarContainer ? createPortal(<><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</>, toolbarContainer) : <div className="flex items-center gap-2"><PluginStatus sourceKind={marketSource?.kind} noVault={noVault} offlineError={catalog.listError} retry={catalog.retryList} />{refresh}</div>}
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
<div className="plugin-marketplace-browser">
{!entry && <div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
<div className="flex items-center gap-3"><Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />
{catalog.listStatus === 'ready' && <p className="shrink-0 text-xs text-muted-foreground" aria-live="polite">{t('settings.plugins.market.results', { count: filtered.length })}</p>}</div>
{catalog.listStatus === 'ready' && catalog.entries.length > 0 && filtered.length === 0 && <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{catalog.listStatus === 'loading' && <p className="text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.listStatus === 'error' && <div role="alert" className="space-y-2 text-xs text-destructive"><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.listStatus === 'ready' && catalog.entries.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.empty')}</p>}
<div className="plugin-marketplace-grid">{filtered.map((item) => {
const isInstalled = installed.some((plugin) => plugin.id === item.id && sameSource(plugin));
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
className="plugin-card min-w-0 flex min-h-[200px] flex-col gap-3 rounded-lg bg-card p-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<div className="flex items-center gap-3"><span className="flex h-8 w-8 shrink-0 items-center justify-center"><PluginBrandIcon entry={item} size={32} className="text-muted-foreground" /></span><span className="min-w-0 break-words text-sm font-semibold leading-5">{item.name}</span></div>
<p className="break-words text-xs text-muted-foreground">{item.author}</p>
<p className="line-clamp-3 break-words text-sm leading-5 text-muted-foreground">{item.description}</p>
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1 text-xs text-muted-foreground"><span>{item.version} · {item.license}</span><span className="text-foreground">{t('settings.plugins.manager.details')}</span></div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">{isInstalled && <span className="rounded bg-muted px-2 py-0.5 text-foreground">{t('settings.plugins.market.installed')}</span>}{!item.artifact && <span className="rounded bg-muted/40 px-2 py-0.5">{t('settings.plugins.market.sourceBadge')}</span>}</div>
</button>;
})}</div>
</div>}
{entry && <article key={entry.id} aria-label={entry.name} className="plugin-marketplace-detail mx-auto w-full max-w-[920px] min-w-0 space-y-6 focus:outline-none">
<Button variant="ghost" size="sm" onClick={back}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.market.back')}</Button>
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 ref={detailRef} tabIndex={-1} className="break-words text-xl font-semibold focus:outline-none">{entry.name}</h4></div><p className="text-xs text-muted-foreground">{entry.author} · {entry.version} · {entry.license}</p>
{installedEntry && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.installed')} · {installedEntry.activeVersion} · {t(installedEntry.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</p>}
</div>
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
{conflictingInstall && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.sources.conflict')} {conflictingInstall.source?.origin ?? t('settings.plugins.sources.local')}</p>}
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p>{catalog.listError && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.market.offlineShort')}</p>}<Button ref={installButtonRef} size="sm" disabled={catalog.busy || Boolean(catalog.listError) || Boolean(conflictingInstall)} onClick={() => { if (marketSource?.kind === 'third-party' || (catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official'))) { setClearPreviousSource(false); setConfirmInstall(true); } else void catalog.install(entry.id); }}>{t('settings.plugins.market.install')}</Button></div> : <div className="flex items-center gap-1 text-xs text-muted-foreground"><span>{t('settings.plugins.market.sourceBadge')}</span><PluginInfo label={t('settings.plugins.market.sourceBadge')}>{t(marketSource?.kind === 'third-party' ? 'settings.plugins.sources.sourceOnly' : 'settings.plugins.market.sourceOnly')}</PluginInfo></div>}
<section><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></section>
<div className="min-w-0 border-t pt-4"><div className="flex items-center gap-1"><h4 className="text-sm font-semibold">README</h4><PluginInfo label={t('settings.plugins.market.readmeInfo')}>{t('settings.plugins.market.readmeNote')}</PluginInfo></div>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.readmeStatus === 'ready' && (catalog.readme?.trim() ? <PluginReadme markdown={catalog.readme} /> : <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.market.emptyReadme')}</p>)}
</div>
</article>}
</div>
<Dialog open={confirmInstall && Boolean(entry)} onOpenChange={setConfirmInstall}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); installButtonRef.current?.focus(); }}>
<DialogTitle>{t('settings.plugins.sources.installConfirm')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
<p className="break-all text-sm font-medium">{marketSource?.origin}</p>
{entry && <PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, source: marketSource, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} />}
{catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && <label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearPreviousSource} onChange={(event) => setClearPreviousSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}<span className="break-all">{catalog.sourceBinding.origin ?? t('settings.plugins.sources.local')}</span></label>}
<Button disabled={catalog.busy || Boolean(catalog.listError) || Boolean(catalog.sourceBinding && catalog.sourceBinding.id !== (marketSource?.id ?? 'official') && !clearPreviousSource)} onClick={() => { if (entry) void catalog.install(entry.id, marketSource?.id, clearPreviousSource).then(() => setConfirmInstall(false)); }}>{t('settings.plugins.market.install')}</Button>
</DialogContent></Dialog>
</section>;
}
export function filterPluginCatalog(entries: NetcattyPluginCatalogEntry[], query: string) {
const needle = query.trim().toLocaleLowerCase();
return entries.filter((entry) => `${entry.name} ${entry.author} ${entry.description}`.toLocaleLowerCase().includes(needle));
}
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageDetails } from './PluginPackageManager';
test('package detail renders manifest resources as text and explains advanced authority', () => {
const plugin = {
id: 'com.example.synthetic', enabled: false, activeVersion: '1.0.0',
archiveSha256: 'a'.repeat(64),
manifest: {
manifestVersion: 1, id: 'com.example.synthetic', name: 'synthetic', version: '1.0.0', publisher: 'example',
engines: { netcatty: '>=1.0.0 <2.0.0', api: '0.1.0-internal' },
main: { node: 'index.js' },
permissions: { required: [{ permission: 'network', resources: ['https://example.com'], reason: '<script>attack()</script>' }] },
},
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
} as NetcattyInstalledPlugin;
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginPackageDetails plugin={plugin} /></I18nProvider>);
assert.match(html, /full Node filesystem and network authority/);
assert.match(html, /https:\/\/example.com/);
assert.match(html, /&lt;script&gt;/);
assert.doesNotMatch(html, /<script>/);
assert.match(html, /not a grant/);
assert.match(html, /aaaaaaaaaaaaaaaa/);
});
@@ -0,0 +1,126 @@
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
import React, { useRef, useState } from 'react';
import { PluginPermissionPanel } from './PluginPermissionPanel';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
const { t, resolvedLocale } = useI18n();
const manifest = plugin.manifest;
if (!manifest) return null;
const permissions = [
...(manifest.permissions?.required ?? []).map((value) => ({ value, required: true })),
...(manifest.permissions?.optional ?? []).map((value) => ({ value, required: false })),
];
const policy = describePluginRuntime(manifest);
const runtimeKind = plugin.runtime.kind ?? policy.defaultRuntimeKind;
const runtimeLabel = runtimeKind === 'utility' ? 'advanced'
: policy.hasNode ? 'dual' : policy.declaresAdvanced ? 'advancedDeclaration' : 'browser';
return <div className="space-y-4 border-t border-border/60 pt-4 text-sm">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-muted-foreground">ID</dt><dd className="break-all font-mono">{plugin.id}</dd>
<dt className="text-muted-foreground">Netcatty</dt><dd>{manifest.engines.netcatty}</dd>
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
</dl>
<p className={!policy.browserOnly && !policy.dataOnly ? 'text-destructive' : 'text-muted-foreground'}>{policy.dataOnly ? t('settings.plugins.manager.dataTheme') : t(`settings.plugins.manager.${runtimeLabel}`)}</p>
<div className="space-y-2">
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
{permissions.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.manager.noPermissions')}</p>}
{permissions.map(({ value, required }, index) => {
const id = typeof value === 'string' ? value : value.permission;
const description = describePluginPermission(id, resolvedLocale);
return <div key={index} className="rounded-md bg-muted/40 p-3">
<div className="flex justify-between gap-3"><span className="text-sm font-medium">{description.label}</span><span className="text-xs text-muted-foreground">{t(required ? 'settings.plugins.manager.required' : 'settings.plugins.manager.optional')}</span></div>
<p className="mt-1 text-xs text-muted-foreground">{description.description}</p>
<code className="mt-1 block break-all text-[10px] text-muted-foreground">{id}</code>
{description.sensitive && <p className="mt-1 text-xs font-medium text-destructive">{t('settings.plugins.manager.sensitive')}</p>}
{(typeof value === 'string' || value.resources.length === 0) && <p className="mt-1 text-xs">{t('settings.plugins.manager.unbounded')}</p>}
{typeof value !== 'string' && <><p className="mt-1 whitespace-pre-wrap break-all text-xs">{value.resources.join('\n')}</p>{value.reason && <p className="mt-1 text-xs text-muted-foreground">{value.reason}</p>}</>}
</div>; })}
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.data')}</p>
</div>;
}
export function PluginPackageManager({ manager, onSelectionChange }: { manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [sourceFilter, setSourceFilter] = useState('');
const rootRef = useRef<HTMLElement>(null);
const scrollTop = useRef(0);
const lastSelection = useRef<string | null>(null);
const selectedPlugin = manager.plugins.find((plugin) => plugin.id === selected);
const nameOf = (plugin: NetcattyInstalledPlugin) => typeof plugin.manifest?.displayName === 'string' ? plugin.manifest.displayName : plugin.manifest?.displayName?.[resolvedLocale] ?? plugin.manifest?.displayName?.en ?? plugin.manifest?.name ?? plugin.id;
const descriptionOf = (plugin: NetcattyInstalledPlugin) => {
const value = plugin.manifest?.description;
return typeof value === 'string' ? value : value?.[resolvedLocale] ?? value?.en ?? '';
};
const choose = (id: string | null) => {
const pane = rootRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
if (id) { scrollTop.current = pane?.scrollTop ?? 0; lastSelection.current = id; }
setSelected(id); setRemoving(null); onSelectionChange?.(id);
requestAnimationFrame(() => {
if (pane) pane.scrollTop = id ? 0 : scrollTop.current;
if (id) rootRef.current?.querySelector<HTMLElement>('[data-plugin-detail]')?.focus({ preventScroll: true });
else Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[data-plugin-card]') ?? []).find((button) => button.dataset.pluginCard === lastSelection.current)?.focus({ preventScroll: true });
});
};
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => (!sourceFilter || (plugin.source?.id ?? 'local') === sourceFilter) && [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const [removing, setRemoving] = useState<string | null>(null);
const [clearSource, setClearSource] = useState(false);
if (!manager.available) return null;
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
<div className={selectedPlugin ? 'space-y-4' : 'grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-3'}>
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg focus:outline-none ${selectedPlugin ? 'space-y-4' : 'plugin-card flex min-h-[200px] flex-col gap-3 bg-card p-4'}`}>
<div className="flex flex-wrap items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
<div className="break-words text-sm font-medium">{nameOf(plugin)}</div>
</button>
<Button variant="ghost" size="sm" type="button"
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${manager.statusKnown !== false && plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
style={manager.statusKnown !== false && plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
aria-pressed={manager.statusKnown === false ? undefined : plugin.enabled} aria-busy={manager.busy}
aria-label={`${t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${plugin.manifest?.name ?? plugin.id}`}
title={t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}
disabled={manager.busy || manager.statusKnown === false} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
{t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
</Button>
</div>
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => choose(plugin.id)} aria-label={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`}>{t('settings.plugins.manager.details')}</Button>
</div> : <p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>}
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={clearSource} onChange={(event) => setClearSource(event.target.checked)} />{t('settings.plugins.sources.clearPrivate')}</label>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id, clearSource); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => { setClearSource(false); setRemoving(plugin.id); }}>{t('settings.plugins.manager.remove')}</Button>}
</>}
</article>)}
</div>
</section>;
}
@@ -0,0 +1,145 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('permission panel refreshes real grants, revokes once, and keeps stale reads from replacing new results', 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', 'Element', 'Node', 'Event', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginPermissionPanel } = await import('./PluginPermissionPanel');
const root = createRoot(document.getElementById('root')!);
const initial = {
grants: [{ permission: 'network', resource: 'https://synthetic.invalid/<text>', resourceKind: 'exact', scope: 'always' as const, sessionId: null, grantedAt: 1, matchesCurrentActivation: false }],
audit: [{ event: 'permission.used', createdAt: 2, permission: 'network' }],
};
let reads = 0;
let revokes = 0;
let changed = 0;
let staleRead: (value: typeof initial) => void = () => {};
let releaseRevoke: () => void = () => {};
const revokePending = new Promise<void>((resolve) => { releaseRevoke = resolve; });
Object.assign(dom.window, { netcatty: {
async getPluginPermissions(id: string) {
assert.equal(id, 'com.example.synthetic');
reads++;
if (reads === 2) return new Promise<typeof initial>((resolve) => { staleRead = resolve; });
return reads > 2 ? { grants: [], audit: [] } : initial;
},
async revokePluginPermissions(id: string) { assert.equal(id, 'com.example.synthetic'); revokes++; await revokePending; },
} });
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => { changed++; }} /></I18nProvider>));
assert.match(document.body.textContent!, /synthetic.invalid/);
assert.equal(document.querySelector('text'), null, 'resource is rendered as plain text');
assert.match(document.body.textContent!, /Until revoked/);
assert.match(document.body.textContent!, /Inactive for the current activation/);
await act(async () => { button('Refresh').click(); });
await act(async () => {
button('Disable and revoke all grants').click();
button('Disable and revoke all grants').click();
});
assert.equal(revokes, 1);
assert.equal(button('Refresh').disabled, true);
await act(async () => { releaseRevoke(); });
assert.equal(changed, 1);
assert.match(document.body.textContent!, /No saved grants/);
await act(async () => { staleRead(initial); });
assert.doesNotMatch(document.body.textContent!, /synthetic.invalid/);
Object.assign(dom.window, { netcatty: { async getPluginPermissions() { throw new Error('synthetic read failed'); } } });
await act(async () => { button('Refresh').click(); });
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic read failed/);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
test('partial revocation failure refreshes actual manager and engine state without clearing the original error', 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', 'Event', 'MouseEvent', 'IS_REACT_ACT_ENVIRONMENT']) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : key === 'IS_REACT_ACT_ENVIRONMENT' ? true : dom.window[key as keyof typeof dom.window] });
}
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginPermissionPanel } = await import('./PluginPermissionPanel');
const { PluginManager } = await import('../../../electron/plugins/pluginManager.cjs');
const { PluginPermissionEngine } = await import('../../../electron/plugins/permissionEngine.cjs');
const { defaultSecurityPrincipal, normalizePermissionDeclarations, permissionDeclarationHash } = await import('../../../electron/plugins/permissionResources.cjs');
const manifest = { id: 'com.example.synthetic', publisher: 'example', permissions: { optional: ['storage'] } };
const principal = defaultSecurityPrincipal(manifest);
const grant = { pluginId: manifest.id, permission: 'storage', resource: '*', resourceKind: 'exact', grantedAt: 1,
declarationHash: permissionDeclarationHash(normalizePermissionDeclarations(manifest).get('storage'), principal) };
let enabled = true, running = true, stopped = false, readFails = false;
let grants = [grant];
let reads = 0, changed = 0;
const database = {
getActivePlugin: () => ({ id: manifest.id, enabled, manifest }),
listPlugins: () => [{ id: manifest.id, enabled, manifest }],
setEnabled(_id: string, value: boolean) { enabled = value; },
listPermissionGrants: () => grants,
deleteAllPermissionGrants() { grants = []; },
recordSecurityAudit() { throw new Error('synthetic audit write failed'); },
};
const engine = new PluginPermissionEngine({ database });
const manager = new PluginManager({ database, permissionEngine: engine,
packageStore: { async initialize() {} },
contributionService: { async initialize() {}, onPluginDisabled() {} },
runtimeSupervisor: { async stop() { running = false; stopped = true; } },
});
Object.assign(dom.window, { netcatty: {
async getPluginPermissions() {
reads++;
if (readFails) throw new Error('synthetic grants refresh failed');
const groups = engine.describeGrants(manifest.id, running ? { pluginId: manifest.id, manifest, securityPrincipal: principal } : null);
return { grants: groups.always, audit: [] };
},
async revokePluginPermissions(id: string) { return manager.revokePermissions(id); },
} });
const root = createRoot(document.getElementById('root')!);
const refreshManager = async () => { changed++; assert.equal((await manager.list())[0].enabled, false); };
const revoke = () => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === 'Disable and revoke all grants')!;
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel key="first" pluginId={manifest.id} onChanged={refreshManager} /></I18nProvider>));
assert.match(document.body.textContent!, /Matches the current activation/);
await act(async () => { revoke().click(); });
assert.equal(enabled, false); assert.equal(stopped, true);
assert.equal(changed, 1); assert.equal(reads, 2);
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic audit write failed/);
assert.match(document.body.textContent!, /No saved grants/);
assert.doesNotMatch(document.body.textContent!, /Matches the current activation/);
// A failed state read must also remove the old grant snapshot while
// retaining the mutation error and reporting the refresh failure.
enabled = true; running = true; stopped = false; grants = [grant];
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel key="second" pluginId={manifest.id} onChanged={async () => { await refreshManager(); readFails = true; throw new Error('synthetic manager refresh failed'); }} /></I18nProvider>));
assert.match(document.body.textContent!, /Matches the current activation/);
await act(async () => { revoke().click(); });
assert.equal(enabled, false); assert.equal(stopped, true);
assert.equal(changed, 2); assert.equal(reads, 4);
const error = document.querySelector('[role="alert"]')?.textContent ?? '';
assert.match(error, /synthetic audit write failed/);
assert.match(error, /synthetic manager refresh failed/);
assert.match(error, /synthetic grants refresh failed/);
assert.doesNotMatch(document.body.textContent!, /Matches the current activation/);
} finally {
await act(async () => root.unmount()); engine.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -0,0 +1,86 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { describePluginPermission } from '../../../domain/pluginPermissionDescriptions.cjs';
import { Button } from '../../ui/button';
/** Host UI only. Plugin views cannot query or mutate this security state. */
export function PluginPermissionPanel({ pluginId, onChanged }: {
pluginId: string;
onChanged: () => Promise<void>;
}) {
const { t, resolvedLocale } = useI18n();
const [snapshot, setSnapshot] = useState<NetcattyPluginPermissionSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const generation = useRef(0);
const pending = useRef(false);
const refresh = useCallback(async (preserveError = false) => {
const current = ++generation.current;
if (!preserveError) setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.getPluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
const result = await bridge.getPluginPermissions(pluginId);
if (current === generation.current) setSnapshot(result);
} catch (cause) {
if (current === generation.current) {
setSnapshot(null);
const message = cause instanceof Error ? cause.message : String(cause);
setError((previous) => preserveError && previous ? `${previous}\n${message}` : message);
}
}
}, [pluginId, t]);
useEffect(() => {
const requestGeneration = generation;
void refresh();
return () => { requestGeneration.current++; };
}, [refresh]);
const revoke = async () => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.revokePluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
await bridge.revokePluginPermissions(pluginId);
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally {
try { await onChanged(); }
catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
setError((previous) => previous ? `${previous}\n${message}` : message);
}
await refresh(true);
pending.current = false; setBusy(false);
}
};
return <section data-host-security-surface="" className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
</div>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
{!snapshot && !error && <p role="status">{t('settings.plugins.permissions.loading')}</p>}
{snapshot && <>
{snapshot.grants.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.empty')}</p>}
{snapshot.grants.map((grant, index) => <div key={index} className="rounded-md bg-muted/40 p-3 text-xs">
<p className="font-medium">{describePluginPermission(grant.permission, resolvedLocale).label}</p>
<p className="whitespace-pre-wrap break-all font-mono">{grant.resource}</p>
<p>{t(grant.matchesCurrentActivation ? 'settings.plugins.permissions.current' : 'settings.plugins.permissions.inactive')}</p>
<p>{t(`settings.plugins.permissions.${grant.scope}`)}{grant.sessionId ? ` · ${grant.sessionId}` : ''}</p>
<time dateTime={new Date(grant.grantedAt).toISOString()}>{new Date(grant.grantedAt).toLocaleString(resolvedLocale)}</time>
</div>)}
<details className="text-xs">
<summary className="cursor-pointer">{t('settings.plugins.permissions.audit')}</summary>
<ol className="mt-2 space-y-1">{snapshot.audit.map((entry, index) => <li key={index} className="break-words">
<time dateTime={new Date(entry.createdAt).toISOString()}>{new Date(entry.createdAt).toLocaleString(resolvedLocale)}</time>{' · '}
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}
</li>)}</ol>
</details>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
<Button variant="destructive" size="sm" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>
</>}
</section>;
}
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { PluginReadme } from './PluginReadme';
test('README never creates active HTML, remote media or script links', () => {
const html = renderToStaticMarkup(<PluginReadme markdown={'# Example\n\n<script>alert(1)</script>\n\n<img src="https://tracker.example/pixel">\n\n![track](https://tracker.example/x.svg)\n\n[run](javascript:alert%281%29)\n\n[docs](https://example.com)\n\n```html\n<iframe src="file:///secret">\n```'} />);
assert.doesNotMatch(html, /<(script|img|iframe|svg)\b/);
assert.doesNotMatch(html, /href="(javascript|file):/);
assert.match(html, /href="https:\/\/example.com"/);
assert.match(html, /rel="noopener noreferrer"/);
assert.match(html, /&lt;script&gt;/);
});
+37
View File
@@ -0,0 +1,37 @@
import React, { useMemo } from 'react';
import { fromMarkdown } from 'mdast-util-from-markdown';
import type { RootContent, Root } from 'mdast';
function renderNode(node: Root | RootContent, key: number, depth = 0): React.ReactNode {
if (depth > 24) return null;
const children = 'children' in node ? node.children.map((child, index) => renderNode(child as RootContent, index, depth + 1)) : null;
switch (node.type) {
case 'root': return <React.Fragment key={key}>{children}</React.Fragment>;
case 'heading': return <h4 key={key} className="mt-5 font-semibold">{children}</h4>;
case 'paragraph': return <p key={key} className="my-2 leading-relaxed">{children}</p>;
case 'text': return node.value;
case 'strong': return <strong key={key}>{children}</strong>;
case 'emphasis': return <em key={key}>{children}</em>;
case 'inlineCode': return <code key={key} className="rounded bg-muted px-1">{node.value}</code>;
case 'code': return <pre key={key} className="my-3 overflow-auto rounded-lg bg-muted p-3 text-xs"><code>{node.value}</code></pre>;
case 'list': return <ul key={key} className="my-2 list-disc pl-5">{children}</ul>;
case 'listItem': return <li key={key}>{children}</li>;
case 'blockquote': return <blockquote key={key} className="border-l-2 pl-3 text-muted-foreground">{children}</blockquote>;
case 'break': return <br key={key} />;
case 'thematicBreak': return <hr key={key} />;
case 'link': {
let safe = false;
try { const url = new URL(node.url); safe = url.protocol === 'https:' && !url.username && !url.password; } catch { /* relative and unsafe links stay text */ }
return safe ? <a key={key} href={node.url} target="_blank" rel="noopener noreferrer" className="underline">{children}</a> : <span key={key}>{children}</span>;
}
// Never embed HTML, media, SVG, iframes, remote images, or reference URLs.
case 'image': return <span key={key} className="text-muted-foreground">[{node.alt ?? 'image'}]</span>;
case 'html': return <span key={key}>{node.value}</span>;
default: return <React.Fragment key={key}>{children}</React.Fragment>;
}
}
export function PluginReadme({ markdown }: { markdown: string }) {
const tree = useMemo(() => fromMarkdown(markdown.slice(0, 256 * 1024)), [markdown]);
return <div className="break-words text-sm">{renderNode(tree, 0)}</div>;
}
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('state pill exposes actual state, opposite action, focus styling and pending disable without granting permissions', async () => {
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const calls: unknown[] = [];
const plugin = { id: 'com.test.pill', activeVersion: '1.0.0', enabled: true, manifest: { name: 'Pill' }, runtime: {} } as NetcattyInstalledPlugin;
const manager = { available: true, plugins: [plugin], busy: false, setEnabled: async (...args: unknown[]) => { calls.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
let renderer: ReactTestRenderer;
const render = (enabled: boolean, busy = false) => <I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, busy, plugins: [{ ...plugin, enabled }] }} /></I18nProvider>;
try {
await act(async () => { renderer = create(render(true)); });
const pill = () => renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
assert.equal(pill().props['aria-pressed'], true);
const logoSlot = renderer.root.findAllByType('div').find((node) => node.props.className === 'flex h-12 w-12 shrink-0 items-center justify-center');
assert.ok(logoSlot, 'logo keeps its 48px layout slot without an extra decorative background');
assert.equal(pill().props['aria-label'], 'Disable Pill');
assert.ok(pill().children.includes('Enabled'));
assert.match(pill().props.className, /rounded-full/);
assert.match(pill().props.className, /border-0/);
assert.doesNotMatch(pill().props.className, /border-primary|border-border/);
assert.match(pill().props.className, /bg-primary\/10/);
assert.match(pill().props.className, /focus-visible:ring-2/);
assert.match(pill().props.style.color, /--primary/);
await act(async () => pill().props.onClick());
assert.deepEqual(calls, [['com.test.pill', false]]);
await act(async () => renderer.update(render(false, true)));
assert.equal(pill().props['aria-pressed'], false);
assert.equal(pill().props['aria-label'], 'Enable Pill');
assert.ok(pill().children.includes('Disabled'));
assert.equal(pill().props.disabled, true);
assert.equal(pill().props['aria-busy'], true);
assert.match(pill().props.className, /bg-muted/);
assert.equal(pill().props.style, undefined);
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; }
});
+14
View File
@@ -0,0 +1,14 @@
import React from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { PluginInfo } from './PluginInfo';
export function PluginStatus({ noVault, offlineError, retry, sourceKind }: { sourceKind?: NetcattyPluginSource['kind']; noVault?: boolean; offlineError?: string | null; retry?: () => void }) {
const { t } = useI18n();
const count = Number(Boolean(noVault)) + Number(Boolean(offlineError));
const explanation = <>{noVault && <p>{t('settings.plugins.manager.noVault')}</p>}{offlineError && <><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.offline' : 'settings.plugins.market.offline')}</p><p className="break-words">{offlineError}</p></>}</>;
const label = count ? `${t('settings.plugins.info')} · ${t('settings.plugins.status', { count })}` : t('settings.plugins.info');
const help = <><p>{t('settings.plugins.manager.preview')}</p><p>{t(sourceKind === 'third-party' ? 'settings.plugins.sources.note' : 'settings.plugins.market.note')}</p></>;
const preview = <>{count > 0 && <p>{t('settings.plugins.status', { count })}</p>}{explanation}{help}</>;
return <PluginInfo label={label} badgeCount={count} previewContent={preview}>{explanation}{offlineError && retry && <Button variant="outline" size="sm" onClick={retry}>{t('settings.plugins.market.retry')}</Button>}{count > 0 && <hr className="border-border/60" />}{help}</PluginInfo>;
}
@@ -0,0 +1,230 @@
import assert from "node:assert/strict";
import test from "node:test";
import { JSDOM } from "jsdom";
import type {
ThemePackCatalog,
ThemePackEntry,
} from "@netcatty/plugin-contract";
test("host theme picker previews without persistence, applies, restores, and cancels stale owners safely", async () => {
const dom = new JSDOM(
'<!doctype html><html><body><div id="root"></div></body></html>',
{ url: "http://localhost", pretendToBeVisual: true },
);
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const key of [
"window",
"document",
"navigator",
"HTMLElement",
"Element",
"Node",
"Event",
"MouseEvent",
"IS_REACT_ACT_ENVIRONMENT",
]) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, {
configurable: true,
writable: true,
value:
key === "IS_REACT_ACT_ENVIRONMENT"
? true
: key === "window"
? dom.window
: dom.window[key as keyof typeof dom.window],
});
}
const { default: React, act, useLayoutEffect } = await import("react");
const { createRoot } = await import("react-dom/client");
const { I18nProvider } = await import(
"../../../application/i18n/I18nProvider"
);
const { PluginThemePicker } = await import("./PluginThemePicker");
const { pluginThemeRegistry: registry } = await import(
"../../../infrastructure/config/pluginThemeRegistry"
);
const { usePluginThemeRevision } = await import(
"../../../application/state/usePluginThemeRuntime"
);
const { getUiThemeById } = await import(
"../../../infrastructure/config/uiThemes"
);
const { applyThemeTokens } = await import(
"../../../application/state/settingsStateDefaults"
);
const entry: ThemePackEntry = {
ref: {
pluginId: "com.example.theme",
themeId: "com.example.theme.forest",
version: "1.0.0",
digest: "a".repeat(64),
variant: "light",
},
label: "Forest",
pluginName: "Synthetic Theme",
dataOnly: true,
palette: { formatVersion: 1, base: "light", colors: { accent: "#166534" } },
};
let writes = 0,
restores = 0;
let catalog: ThemePackCatalog = {
selection: {
formatVersion: 1,
revision: registry.getCatalog().selection.revision + 1,
light: null,
dark: null,
},
entries: [entry],
diagnostics: [],
};
registry.setCatalog(catalog);
Object.assign(dom.window, {
netcatty: {
async applyPluginTheme(request: {
expectedRevision: number;
choice: ThemePackCatalog["selection"]["light"];
}) {
assert.equal(request.expectedRevision, catalog.selection.revision);
writes++;
catalog = {
...catalog,
selection: {
...catalog.selection,
revision: catalog.selection.revision + 1,
light: request.choice,
},
};
registry.setCatalog(catalog);
return catalog;
},
},
});
function Fixture() {
const revision = usePluginThemeRevision();
useLayoutEffect(
() =>
applyThemeTokens(
"light",
"light",
getUiThemeById("light", "snow").tokens,
"theme",
"",
),
[revision],
);
return (
<I18nProvider locale="en">
<PluginThemePicker mode="light" onRestore={() => restores++} />
</I18nProvider>
);
}
const root = createRoot(document.getElementById("root")!);
const button = (label: string) =>
[...document.querySelectorAll<HTMLButtonElement>("button")].find(
(b) => b.textContent === label,
)!;
try {
await act(async () => root.render(<Fixture />));
const original =
document.documentElement.style.getPropertyValue("--primary");
await act(async () => {
const select = document.querySelector("select")!;
select.value = `${entry.ref.pluginId}/${entry.ref.themeId}`;
select.dispatchEvent(new Event("change", { bubbles: true }));
});
await act(async () => button("Preview").click());
assert.equal(writes, 0);
assert.equal(
getUiThemeById("light", "snow", false).tokens.primary,
original,
"local preview does not enter the global plugin view environment",
);
assert.notEqual(
document.documentElement.style.getPropertyValue("--primary"),
original,
);
assert.ok(
document.documentElement.hasAttribute("data-plugin-theme-active"),
);
assert.ok(
document.documentElement.style.getPropertyValue(
"--host-security-primary",
),
);
await act(async () => button("Cancel preview").click());
assert.equal(
document.documentElement.style.getPropertyValue("--primary"),
original,
);
assert.equal(writes, 0);
await act(async () => {
button("Preview").click();
button("Apply").click();
});
assert.equal(writes, 1);
assert.equal(catalog.selection.light?.ref.themeId, entry.ref.themeId);
await act(async () => button("Restore default").click());
assert.equal(writes, 2);
assert.equal(restores, 1);
assert.equal(catalog.selection.light, null);
await act(async () => {
registry.startPreview("old-owner", entry);
registry.startPreview("new-owner", entry);
registry.cancelPreview("old-owner");
});
assert.notEqual(
document.documentElement.style.getPropertyValue("--primary"),
original,
);
await act(async () =>
registry.setCatalog({
...catalog,
selection: {
...catalog.selection,
revision: catalog.selection.revision + 1,
},
entries: [],
}),
);
assert.equal(
document.documentElement.style.getPropertyValue("--primary"),
original,
"package withdrawal cancels previews",
);
await act(async () =>
registry.setCatalog({
...catalog,
selection: {
...catalog.selection,
revision: catalog.selection.revision + 2,
light: { ref: entry.ref },
},
entries: [],
}),
);
assert.equal(getUiThemeById("light", "snow").id, "host-plugin-fallback");
await act(async () => registry.setHighContrast(true));
assert.equal(getUiThemeById("light", "snow").id, "host-high-contrast");
} finally {
await act(async () => {
root.unmount();
registry.setHighContrast(false);
registry.setCatalog({
...catalog,
selection: {
formatVersion: 1,
revision: registry.getCatalog().selection.revision + 1,
light: null,
dark: null,
},
entries: [],
});
});
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,166 @@
import { useEffect, useId, useState } from "react";
import { useI18n } from "../../../application/i18n/I18nProvider";
import {
applyPluginThemeSelection,
usePluginThemeRevision,
} from "../../../application/state/usePluginThemeRuntime";
import { pluginThemeRegistry } from "../../../infrastructure/config/pluginThemeRegistry";
import { Button } from "../../ui/button";
export function PluginThemePicker({
mode,
onRestore,
}: {
mode: "light" | "dark";
onRestore: () => void;
}) {
const { t } = useI18n();
usePluginThemeRevision();
const owner = useId();
const [selected, setSelected] = useState(""),
[pending, setPending] = useState(false),
[error, setError] = useState("");
const catalog = pluginThemeRegistry.getCatalog();
const entries = catalog.entries.filter((e) => e.palette.base === mode);
const applied = catalog.selection[mode];
const appliedAvailable =
applied &&
entries.some(
(e) =>
e.ref.pluginId === applied.ref.pluginId &&
e.ref.themeId === applied.ref.themeId &&
e.ref.version === applied.ref.version &&
e.ref.digest === applied.ref.digest,
);
const entry = entries.find(
(e) => `${e.ref.pluginId}/${e.ref.themeId}` === selected,
);
useEffect(
() => () => pluginThemeRegistry.cancelPreview(owner),
[owner, mode],
);
async function apply(restore = false) {
if (pending || (!restore && !entry)) return;
setPending(true);
setError("");
try {
await applyPluginThemeSelection(
mode,
restore ? null : { ref: entry!.ref },
);
pluginThemeRegistry.cancelPreview(owner);
if (restore) {
setSelected("");
onRestore();
}
} catch (e) {
setError(
e instanceof Error ? e.message : t("settings.plugins.themes.failed"),
);
} finally {
setPending(false);
}
}
return (
<section
className="space-y-3 py-3"
aria-label={t("settings.plugins.themes.title")}
>
<div>
<h4 className="text-sm font-medium">
{t("settings.plugins.themes.title")}
</h4>
<p className="text-xs text-muted-foreground">
{t("settings.plugins.themes.description")}
</p>
</div>
{entries.length === 0 ? (
<p className="text-xs text-muted-foreground">
{t("settings.plugins.themes.empty")}
</p>
) : (
<select
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
aria-label={t("settings.plugins.themes.choose")}
value={entry ? selected : ""}
disabled={pending}
onChange={(e) => {
pluginThemeRegistry.cancelPreview(owner);
setSelected(e.target.value);
setError("");
}}
>
<option value="">{t("settings.plugins.themes.choose")}</option>
{entries.map((e) => (
<option
key={`${e.ref.pluginId}/${e.ref.themeId}`}
value={`${e.ref.pluginId}/${e.ref.themeId}`}
>
{e.label} — {e.pluginName} {e.ref.version}
</option>
))}
</select>
)}
{catalog.selection[mode] && (
<p className="text-xs text-muted-foreground">
{t(
appliedAvailable
? "settings.plugins.themes.applied"
: "settings.plugins.themes.unavailable",
)}
: {catalog.selection[mode]!.ref.themeId} ·{" "}
{catalog.selection[mode]!.ref.version}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
disabled={!entry || pending}
onClick={() => {
if (entry) pluginThemeRegistry.startPreview(owner, entry);
}}
>
{t("settings.plugins.themes.preview")}
</Button>
<Button
size="sm"
disabled={!entry || pending}
onClick={() => {
void apply();
}}
>
{t("settings.plugins.themes.apply")}
</Button>
<Button
size="sm"
variant="ghost"
disabled={pending}
onClick={() => pluginThemeRegistry.cancelPreview(owner)}
>
{t("settings.plugins.themes.cancel")}
</Button>
<Button
size="sm"
variant="outline"
disabled={pending}
onClick={() => {
void apply(true);
}}
>
{t("settings.plugins.themes.restore")}
</Button>
</div>
{catalog.diagnostics.length > 0 && (
<p className="text-xs text-muted-foreground">
{t("settings.plugins.themes.invalid")}
</p>
)}
{error && (
<p role="alert" className="text-xs text-destructive">
{error}
</p>
)}
</section>
);
}
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('source management requires consent and Escape restores either opener inside the plugin window', async () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window));
install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window));
install('IS_REACT_ACT_ENVIRONMENT', true);
// Install a synthetic preload surface on the isolated DOM window.
Object.defineProperty(dom.window, 'netcatty', { value: { listPluginSources: async () => [] }, configurable: true });
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { PluginThirdPartyMarket } = await import('./PluginThirdPartyMarket');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const root = createRoot(document.getElementById('root')!);
const settle = () => new Promise((resolve) => setTimeout(resolve, 60));
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog><PluginThirdPartyMarket installed={[]} /></PluginManagementDialog></I18nProvider>));
await act(async () => { document.querySelector<HTMLButtonElement>('button')!.click(); await settle(); });
for (const label of ['Manage sources', 'Add source']) {
const opener = [...document.querySelectorAll<HTMLButtonElement>('button')].find((button) => button.textContent === label)!;
assert.ok(opener);
await act(async () => { opener.focus(); opener.click(); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 2);
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"]');
const dialog = dialogs[dialogs.length - 1];
assert.match(dialog.textContent ?? '', /real domain/);
assert.equal(dialog.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked, false);
assert.equal(dialog.querySelector<HTMLButtonElement>('button[type="submit"]')?.disabled, true);
await act(async () => { dialog.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })); await settle(); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(document.activeElement, opener);
}
} finally {
// Radix schedules unmount autofocus on a timer. Drain it while the DOM's
// Event constructor is still installed (Node 22 has a distinct global Event).
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,59 @@
import React, { useRef, useState } from 'react';
import { usePluginSources } from '../../../application/state/usePluginSources';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../../ui/dialog';
import { PluginMarketplace } from './PluginMarketplace';
export function PluginThirdPartyMarket({ installed, toolbarContainer, noVault }: {
installed: NetcattyInstalledPlugin[]; toolbarContainer?: HTMLElement | null; noVault?: boolean;
}) {
const { t } = useI18n();
const model = usePluginSources();
const [selected, setSelected] = useState('');
const [visited, setVisited] = useState<string[]>([]);
const [managing, setManaging] = useState(false);
const opener = useRef<HTMLButtonElement | null>(null);
const openManager = (event: React.MouseEvent<HTMLButtonElement>) => { opener.current = event.currentTarget; setManaging(true); };
const [name, setName] = useState('');
const [address, setAddress] = useState('');
const [accepted, setAccepted] = useState(false);
const [removing, setRemoving] = useState<string | null>(null);
const [disablePlugins, setDisablePlugins] = useState(false);
const sources = model.sources.filter((source) => source.kind === 'third-party');
const source = sources.find((item) => item.id === selected && item.enabled);
let domain = '';
try { domain = new URL(address).host; } catch { /* Host validates before saving. */ }
return <div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<select aria-label={t('settings.plugins.sources.select')} value={selected} onChange={(event) => { setSelected(event.target.value); setVisited((items) => [...new Set([...items, event.target.value])]); }} className="h-9 min-w-0 rounded-md border border-input bg-background px-3 text-sm">
<option value="">{t('settings.plugins.sources.select')}</option>
{sources.filter((item) => item.enabled).map((item) => <option key={item.id} value={item.id}>{item.name} — {new URL(item.origin!).hostname}</option>)}
</select>
<Button variant="outline" size="sm" onClick={openManager}>{t('settings.plugins.sources.manage')}</Button>
</div>
{!source && <div className="py-8 text-center text-sm text-muted-foreground"><p>{t('settings.plugins.sources.empty')}</p><Button className="mt-3" variant="outline" onClick={openManager}>{t('settings.plugins.sources.add')}</Button></div>}
{/* Keep each visited source mounted to preserve its own selection/search/scroll. */}
{sources.filter((item) => item.enabled && (item.id === selected || visited.includes(item.id))).map((item) => {
return <div key={item.id} hidden={item.id !== source?.id}><PluginMarketplace source={item} installed={installed} toolbarContainer={item.id === source?.id ? toolbarContainer : undefined} noVault={noVault} /></div>;
})}
<Dialog open={managing} onOpenChange={setManaging}><DialogContent className="max-h-[85vh] overflow-y-auto" onCloseAutoFocus={(event) => { event.preventDefault(); opener.current?.focus(); }}>
<DialogTitle>{t('settings.plugins.sources.manage')}</DialogTitle><DialogDescription>{t('settings.plugins.sources.risk')}</DialogDescription>
{model.error && <p role="alert" className="text-sm text-destructive">{model.error}</p>}
<div className="space-y-3">{sources.map((item) => <div key={item.id} className="space-y-2 rounded-lg bg-muted/30 p-3">
<Input aria-label={t('settings.plugins.sources.name')} defaultValue={item.name} onBlur={(event) => { if (event.target.value !== item.name) void model.update(item, event.target.value, Boolean(item.enabled)); }} disabled={model.busy} />
<p className="break-all text-xs text-muted-foreground">{item.origin}</p>
<div className="flex items-center justify-between"><label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={Boolean(item.enabled)} disabled={model.busy} onChange={(event) => void model.update(item, item.name, event.target.checked)} />{t('settings.plugins.manager.enabled')}</label><Button variant="ghost" size="sm" disabled={model.busy} onClick={() => { setRemoving(item.id); setDisablePlugins(false); }}>{t('settings.plugins.sources.remove')}</Button></div>
{removing === item.id && <div className="space-y-2 text-sm"><p>{t('settings.plugins.sources.removeNote')}</p><label className="flex items-center gap-2"><input type="checkbox" checked={disablePlugins} onChange={(event) => setDisablePlugins(event.target.checked)} />{t('settings.plugins.sources.disablePlugins')}</label><Button size="sm" variant="destructive" disabled={model.busy} onClick={() => void model.remove(item.id, disablePlugins).then((ok) => { if (ok) setRemoving(null); })}>{t('settings.plugins.sources.remove')}</Button></div>}
</div>)}</div>
<form className="space-y-3" onSubmit={(event) => { event.preventDefault(); if (accepted) void model.add(name, address).then((ok) => { if (ok) { setName(''); setAddress(''); setAccepted(false); } }); }}>
<Input required value={name} onChange={(event) => setName(event.target.value)} placeholder={t('settings.plugins.sources.name')} aria-label={t('settings.plugins.sources.name')} />
<Input required type="url" value={address} onChange={(event) => { setAddress(event.target.value); setAccepted(false); }} placeholder="https://plugins.example.com" aria-label={t('settings.plugins.sources.address')} />
{domain && <p className="break-all text-sm font-medium">{domain}</p>}
<label className="flex items-start gap-2 text-xs"><input type="checkbox" checked={accepted} onChange={(event) => setAccepted(event.target.checked)} />{t('settings.plugins.sources.accept')}</label>
<Button type="submit" disabled={model.busy || !accepted}>{t('settings.plugins.sources.add')}</Button>
</form>
</DialogContent></Dialog>
</div>;
}
@@ -1,3 +1,6 @@
import { PluginThemePicker } from './PluginThemePicker';
import { applyPluginThemeSelection, usePluginThemeRevision } from '../../../application/state/usePluginThemeRuntime';
import { pluginThemeRegistry } from '../../../infrastructure/config/pluginThemeRegistry';
import React, { memo, useCallback, useMemo, useState } from "react";
import { applyCustomCssToDocument } from "../../../lib/customCss";
import { DebouncedTextarea } from "../DebouncedTextarea";
@@ -70,6 +73,8 @@ function SettingsAppearanceTab(props: {
setAppIconVariant: (variant: AppIconVariant) => void;
}) {
const { t } = useI18n();
usePluginThemeRevision();
const [pluginThemeError, setPluginThemeError] = useState('');
const availableUIFonts = useAvailableUIFonts();
// Note code fonts come from the monospace-only store (fontStore); the note
// body font follows the UI font setting instead.
@@ -231,8 +236,12 @@ function SettingsAppearanceTab(props: {
);
const visibleUiThemes = resolvedTheme === "dark" ? DARK_UI_THEMES : LIGHT_UI_THEMES;
const visibleUiThemeId = resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
const setVisibleUiThemeId = resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId;
const visibleUiThemeId = pluginThemeRegistry.isActive(resolvedTheme) ? "" : resolvedTheme === "dark" ? darkUiThemeId : lightUiThemeId;
const setVisibleUiThemeId = (id: string) => {
void applyPluginThemeSelection(resolvedTheme, null).then(() => {
(resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId)(id);setPluginThemeError('');
}).catch((error: unknown) => setPluginThemeError(error instanceof Error ? error.message : t('settings.plugins.themes.failed')));
};
return (
<SettingsTabContent value="appearance">
@@ -339,6 +348,8 @@ function SettingsAppearanceTab(props: {
{renderThemeSwatches(visibleUiThemes, visibleUiThemeId, setVisibleUiThemeId)}
</div>
</SettingsAnchor>
<PluginThemePicker mode={resolvedTheme} onRestore={() => (resolvedTheme === "dark" ? setDarkUiThemeId : setLightUiThemeId)(resolvedTheme === "dark" ? "midnight" : "snow")} />
{pluginThemeError && <p role="alert" className="text-xs text-destructive">{pluginThemeError}</p>}
<SettingRow
anchorId="appearance-accent-mode"
label={t("settings.appearance.accentColor.mode")}
+52 -17
View File
@@ -1,9 +1,16 @@
import { PluginThirdPartyMarket } from './PluginThirdPartyMarket';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import { PluginStatus } from './PluginStatus';
import React, { useEffect, useMemo, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { SettingsAnchor, SettingsTabContent } from '../settings-ui';
@@ -278,8 +285,12 @@ export function PluginSettingField({
);
}
export default function SettingsPluginsTab() {
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
const { t } = useI18n();
const manager = usePluginManager();
const [catalogToolbar, setCatalogToolbar] = useState<HTMLDivElement | null>(null);
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace' | 'thirdparty'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
@@ -292,21 +303,31 @@ export default function SettingsPluginsTab() {
}), [scopeIds]);
const contributions = usePluginContributions(query);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application')),
[contributions.snapshot.plugins]);
const hasVisibleContributions = contributions.snapshot.plugins.some((plugin) => (
plugin.settings.some((setting) => setting.visible)
|| plugin.views.some((view) => view.visible && view.location === 'settings')
));
[contributions.snapshot.plugins, selectedInstalled]);
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="mx-auto w-full max-w-3xl space-y-6 px-8 py-8">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace' | 'thirdparty'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
<TabsTrigger value="marketplace">{t('settings.plugins.sources.official')}</TabsTrigger>
<TabsTrigger value="thirdparty">{t('settings.plugins.sources.thirdparty')}</TabsTrigger>
</TabsList>
<div className="flex items-center gap-2">
{pluginView === 'installed' && <PluginStatus noVault={manager.credentialStorageAvailable === false} />}
<div ref={setCatalogToolbar} className="flex items-center gap-2" />
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
</div>
</div>
<TabsContent forceMount hidden={pluginView !== 'marketplace'} value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} toolbarContainer={pluginView === 'marketplace' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent forceMount hidden={pluginView !== 'thirdparty'} value="thirdparty" className="m-0 space-y-6"><PluginThirdPartyMarket installed={manager.plugins} toolbarContainer={pluginView === 'thirdparty' ? catalogToolbar : undefined} noVault={manager.credentialStorageAvailable === false} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
@@ -326,7 +347,7 @@ export default function SettingsPluginsTab() {
)}
{contributions.loading && <p className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{contributions.error && <p role="alert" className="text-sm text-destructive">{contributions.error.message}</p>}
{contributions.snapshot.plugins.map((plugin) => {
{contributions.snapshot.plugins.filter((plugin) => plugin.id === selectedInstalled).map((plugin) => {
const settings = plugin.settings.filter((setting) => setting.visible);
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
@@ -356,18 +377,32 @@ export default function SettingsPluginsTab() {
<div className="font-mono text-[10px] text-muted-foreground">{view.id}</div>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => requestOpenPluginView({
<Button type="button" variant="outline" size="sm" onClick={() => handoff(() => requestOpenPluginView({
viewId: view.id,
context: { 'netcatty.surface': 'settings' },
})}>{t('common.open')}</Button>
}))}>{t('settings.plugins.openView')}</Button>
</div>
))}
</section>
);
})}
{!contributions.loading && contributions.available && !hasVisibleContributions && (
<p className="text-sm text-muted-foreground">{t('settings.plugins.empty')}</p>
)}
</TabsContent>
</Tabs>
</div>
);
}
export default function SettingsPluginsTab() {
const { t } = useI18n();
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="space-y-5">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
</SettingsAnchor>
</SettingsTabContent>
);
@@ -0,0 +1,9 @@
.plugin-marketplace-browser { min-width: 0; }
.plugin-marketplace-catalog { min-width: 0; }
.plugin-marketplace-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr)); gap: 16px; }
/* Details use only the manager's main scrolling surface at every width. */
.plugin-marketplace-detail { overflow-wrap: anywhere; }
.plugin-card { box-shadow: 0 1px 4px hsl(var(--foreground) / 0.06); }
.plugin-card:hover { box-shadow: 0 2px 8px hsl(var(--foreground) / 0.09); }
.dark .plugin-card { background: hsl(var(--muted) / 0.2); box-shadow: none; }
.dark .plugin-card:hover { background: hsl(var(--muted) / 0.3); box-shadow: none; }
@@ -30,7 +30,7 @@ export const TerminalHostKeyVerification: React.FC<TerminalHostKeyVerificationPr
const Icon = isChanged ? AlertTriangle : Fingerprint;
return (
<div className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
<div data-host-security-surface="" className="space-y-3 animate-in fade-in-0 slide-in-from-bottom-1 duration-200">
<div
className={cn(
"rounded-xl border px-3 py-2.5",
+2
View File
@@ -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>
-123
View File
@@ -10,11 +10,9 @@ import {
formatTerminalTitleConnectionAddress,
focusTerminalFromDisconnectedNotice,
getLineTimestampToggleHostUpdate,
resolveLineTimestampGutterLayout,
resolveNetworkDeviceTipRightInset,
resolveTerminalRightInset,
resolveTerminalTopOffsets,
subscribeTerminalAlternateScreenChanges,
shouldBlockTerminalReconnectForTarget,
shouldEnableStatusBarDisconnect,
shouldEnableStatusBarReconnect,
@@ -184,127 +182,6 @@ test("line timestamp toolbar toggle is hidden when timestamps are unavailable",
assert.equal(shouldShowLineTimestampToolbarToggle(true, undefined), false);
});
test("line timestamp gutter reserves no space while the alternate screen is active", () => {
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: true,
alternateScreenActive: true,
gutterWidth: 72,
}), { enabled: false, width: 0 });
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: true,
alternateScreenActive: false,
gutterWidth: 72,
}), { enabled: true, width: 72 });
assert.deepEqual(resolveLineTimestampGutterLayout({
showLineTimestampGutter: false,
alternateScreenActive: false,
gutterWidth: 72,
}), { enabled: false, width: 0 });
const source = readFileSync(new URL("./TerminalView.tsx", import.meta.url), "utf8");
assert.match(source, /const activeLineTimestampGutterWidth = lineTimestampGutterLayout\.width/);
assert.match(source, /enabled=\{lineTimestampGutterLayout\.enabled\}/);
assert.doesNotMatch(source, /enabled=\{showLineTimestampGutter\}/);
assert.match(source, /aria-pressed=\{showLineTimestampGutter\}/);
assert.doesNotMatch(source, /aria-pressed=\{[^}]*alternateScreenActive/);
});
type AlternateScreenWatchTerm = {
buffer: { active: { type: string } };
onRender: (callback: () => void) => { dispose: () => void };
};
function createAlternateScreenWatchTerm(type: "normal" | "alternate") {
const renders: Array<() => void> = [];
let disposed = 0;
const term: AlternateScreenWatchTerm = {
buffer: { active: { type } },
onRender(callback) {
renders.push(callback);
return { dispose: () => { disposed += 1; } };
},
};
return {
term,
renders,
disposeCount: () => disposed,
};
}
test("alternate screen enter and leave drive the timestamp gutter", (t) => {
const watched = createAlternateScreenWatchTerm("normal");
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => watched.term,
(active) => { seen.push(active); },
);
t.after(stop);
assert.deepEqual(seen, [false]);
watched.term.buffer.active.type = "alternate";
watched.renders[0]();
watched.renders[0]();
watched.term.buffer.active.type = "normal";
watched.renders[0]();
assert.deepEqual(seen, [false, true, false]);
stop();
assert.equal(watched.disposeCount(), 1);
watched.term.buffer.active.type = "alternate";
watched.renders[0]();
assert.deepEqual(seen, [false, true, false]);
});
test("alternate screen watch attaches after the terminal runtime exists", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const watched = createAlternateScreenWatchTerm("alternate");
let current: AlternateScreenWatchTerm | null = null;
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => current,
(active) => { seen.push(active); },
);
t.after(() => {
stop();
t.mock.timers.reset();
});
assert.deepEqual(seen, [false]);
current = watched.term;
t.mock.timers.tick(50);
assert.deepEqual(seen, [false, true]);
assert.equal(watched.renders.length, 1);
t.mock.timers.tick(500);
assert.equal(watched.renders.length, 1);
assert.deepEqual(seen, [false, true]);
});
test("alternate screen watch follows a replacement terminal runtime", (t) => {
t.mock.timers.enable({ apis: ["setTimeout"] });
const first = createAlternateScreenWatchTerm("alternate");
const second = createAlternateScreenWatchTerm("normal");
let current: AlternateScreenWatchTerm = first.term;
const seen: boolean[] = [];
const stop = subscribeTerminalAlternateScreenChanges(
() => current,
(active) => { seen.push(active); },
);
t.after(() => {
stop();
t.mock.timers.reset();
});
assert.deepEqual(seen, [true]);
current = second.term;
t.mock.timers.tick(250);
assert.deepEqual(seen, [true, false]);
assert.equal(first.disposeCount(), 1);
assert.equal(second.renders.length, 1);
second.term.buffer.active.type = "alternate";
second.renders[0]();
assert.deepEqual(seen, [true, false, true]);
});
test("selection AI overlay honors the visibility preference", () => {
const overlayPosition = { left: 120, top: 80 };
const addSelection = () => {};
+2 -116
View File
@@ -1,6 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { memo, useCallback, useEffect, useRef, useState } from 'react';
import type { Terminal as XTerm } from '@xterm/xterm';
import { ChevronsLeft, GripVertical, Minimize2, Network, PanelLeft, X as XIcon } from 'lucide-react';
import { isTerminalSensitiveInputActive } from './runtime/terminalSensitiveInputRegistry';
import { isSessionReconnectDisabled } from '../top-tabs/SessionTabContextMenuContent';
@@ -19,7 +18,6 @@ import {
resolveTerminalTimestampGutterColor,
resolveTerminalTimestampGutterWidth,
} from './TerminalTimestampGutter';
import { isTerminalAlternateScreenActive } from './terminalHibernateRuntime';
import {
Dialog,
DialogContent,
@@ -124,108 +122,6 @@ export function shouldShowLineTimestampToolbarToggle(
return lineTimestampsAvailable !== false && Boolean(onUpdateHost);
}
export function resolveLineTimestampGutterLayout({
showLineTimestampGutter,
alternateScreenActive,
gutterWidth,
}: {
showLineTimestampGutter: boolean;
alternateScreenActive: boolean;
gutterWidth: number;
}): { enabled: boolean; width: number } {
const enabled = showLineTimestampGutter && !alternateScreenActive;
return { enabled, width: enabled ? gutterWidth : 0 };
}
const ALTERNATE_SCREEN_ATTACH_RETRY_MS = 50;
const ALTERNATE_SCREEN_RUNTIME_WATCH_MS = 250;
type TerminalAlternateScreenDisposable = { dispose: () => void };
type TerminalAlternateScreenWatchTarget = {
buffer?: { active?: { type?: string } };
onRender?: (callback: () => void) => TerminalAlternateScreenDisposable;
};
const readAlternateScreenActive = (
term: TerminalAlternateScreenWatchTarget | null | undefined,
): boolean => {
try {
if (!term?.buffer?.active) return false;
return isTerminalAlternateScreenActive(term as XTerm);
} catch {
return false;
}
};
/**
* Alternate-screen programs intentionally skip timestamp recording. onRender
* collapses and restores the gutter immediately. The timer attaches once xterm
* exists and follows a replacement runtime after hibernate or reconnect.
*/
export function subscribeTerminalAlternateScreenChanges(
getTerm: () => TerminalAlternateScreenWatchTarget | null | undefined,
onChange: (alternateScreenActive: boolean) => void,
): () => void {
let disposed = false;
let timer: ReturnType<typeof setTimeout> | null = null;
let renderDisposable: TerminalAlternateScreenDisposable | null = null;
let attachedTerm: TerminalAlternateScreenWatchTarget | null = null;
let lastActive: boolean | null = null;
const publish = (active: boolean) => {
if (disposed || lastActive === active) return;
lastActive = active;
onChange(active);
};
const detachRender = () => {
renderDisposable?.dispose();
renderDisposable = null;
};
const watch = () => {
if (disposed) return;
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
const term = getTerm() ?? null;
if (term !== attachedTerm) {
detachRender();
attachedTerm = term;
if (term?.onRender) {
renderDisposable = term.onRender(() => {
if (disposed) return;
const current = getTerm() ?? null;
if (current !== term) {
watch();
return;
}
publish(readAlternateScreenActive(current));
});
}
}
publish(readAlternateScreenActive(term));
if (disposed) return;
const delay = term?.onRender
? ALTERNATE_SCREEN_RUNTIME_WATCH_MS
: ALTERNATE_SCREEN_ATTACH_RETRY_MS;
timer = setTimeout(watch, delay);
};
watch();
return () => {
disposed = true;
if (timer !== null) clearTimeout(timer);
detachRender();
attachedTerm = null;
};
}
/** Keep the tab/pane; only tear down the live transport. */
export function shouldEnableStatusBarDisconnect(
status: 'connecting' | 'connected' | 'disconnected' | undefined,
@@ -590,11 +486,6 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
}, [hostTimestampsEnabled, timestampOverride]);
const showLineTimestampGutter = lineTimestampsAvailable !== false
&& (timestampOverride ?? hostTimestampsEnabled);
const [alternateScreenActive, setAlternateScreenActive] = useState(false);
useEffect(() => subscribeTerminalAlternateScreenChanges(
() => termRef.current,
setAlternateScreenActive,
), [sessionId, status, termRef]);
const lineTimestampColor = resolveTerminalTimestampGutterColor(effectiveTheme.colors);
const [lineTimestampGutterWidth, setLineTimestampGutterWidth] = useState(() => (
resolveTerminalTimestampGutterWidth({ fontSize: effectiveFontSize })
@@ -606,12 +497,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
const handleLineTimestampGutterWidthChange = useCallback((width: number) => {
setLineTimestampGutterWidth((current) => (current === width ? current : width));
}, []);
const lineTimestampGutterLayout = resolveLineTimestampGutterLayout({
showLineTimestampGutter,
alternateScreenActive,
gutterWidth: lineTimestampGutterWidth,
});
const activeLineTimestampGutterWidth = lineTimestampGutterLayout.width;
const activeLineTimestampGutterWidth = showLineTimestampGutter ? lineTimestampGutterWidth : 0;
const lineTimestampToggleLabel = showLineTimestampGutter
? t("terminal.toolbar.timestampsDisable")
: t("terminal.toolbar.timestampsEnable");
@@ -1200,7 +1086,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
<TerminalTimestampGutter
termRef={termRef}
containerRef={containerRef}
enabled={lineTimestampGutterLayout.enabled}
enabled={showLineTimestampGutter}
top={terminalContentTop}
left={terminalBodyInset}
bottom={terminalBottomInset}
@@ -5,7 +5,6 @@ import { createRequire } from "node:module";
import type { SerializeAddon as SerializeAddonType } from "@xterm/addon-serialize";
import type { Terminal as XTermType } from "@xterm/xterm";
import { DEFAULT_KEYWORD_HIGHLIGHT_RULES } from "../../domain/models/terminal.ts";
import { writeTerminalDataWithLineTimestamps } from "./runtime/terminalLineTimestamps.ts";
import {
noteTerminalOutputPressureData,
@@ -82,76 +81,6 @@ test("new output is colored on the written cells without decorations", async ()
term.dispose();
});
test("keyword highlight skips cells whose PTY foreground is already colored", async () => {
// Starship directory segments set a truecolor foreground (#3940). Those
// cells must keep the PTY color; only default-foreground matches are recolored.
const unixPath = DEFAULT_KEYWORD_HIGHLIGHT_RULES.find((entry) => entry.id === "unix-path");
assert.ok(unixPath);
const keywordRgb = Number.parseInt(unixPath.color.slice(1), 16);
const cream = 0xfbf1c7;
const yellow = 0xfabd2f;
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
highlighter.setRules([unixPath], true);
await write(
term,
"\x1b[38;2;251;241;199m\x1b[48;2;250;189;47m/opt\x1b[0m"
+ " \x1b[38;5;220m/mnt\x1b[0m"
+ " \x1b[31m/bin\x1b[0m"
+ " /var",
);
const tokenCells = (text: string) => {
const line = term.buffer.active.getLine(0);
const index = (line?.translateToString(true) ?? "").indexOf(text);
assert.ok(index >= 0, text);
return Array.from({ length: text.length }, (_, offset) => {
const cell = line?.getCell(index + offset);
assert.ok(cell, `${text}[${offset}]`);
return cell;
});
};
for (const cell of tokenCells("/opt")) {
assert.equal(cell.isFgRGB(), true);
assert.equal(cell.getFgColorMode(), 0x3000000);
assert.equal(cell.getFgColor(), cream);
assert.equal(cell.isBgRGB(), true);
assert.equal(cell.getBgColor(), yellow);
}
for (const cell of tokenCells("/mnt")) {
assert.equal(cell.isFgPalette(), true);
assert.equal(cell.getFgColorMode(), 0x2000000);
assert.equal(cell.getFgColor(), 220);
}
for (const cell of tokenCells("/bin")) {
assert.equal(cell.isFgPalette(), true);
assert.equal(cell.getFgColorMode(), 0x1000000);
assert.equal(cell.getFgColor(), 1);
}
for (const cell of tokenCells("/var")) {
assert.equal(cell.isFgRGB(), true);
assert.equal(cell.getFgColor(), keywordRgb);
}
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], true);
await highlighter.whenSettled();
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
assert.equal(tokenCells("/var")[0]?.getFgColor(), BLUE);
highlighter.setRules([{ ...unixPath, color: "#60A5FA" }], false);
await highlighter.whenSettled();
assert.equal(tokenCells("/opt")[0]?.getFgColor(), cream);
assert.equal(tokenCells("/mnt")[0]?.getFgColor(), 220);
assert.equal(tokenCells("/bin")[0]?.getFgColor(), 1);
assert.equal(tokenCells("/var")[0]?.getFgColor(), -1);
highlighter.dispose();
term.dispose();
});
test("ordinary Enter output does not revisit existing history", async () => {
const term = new XTerm({ allowProposedApi: true, cols: 80, rows: 5, scrollback: 20 });
const highlighter = new KeywordHighlighter(term);
+1 -8
View File
@@ -67,7 +67,6 @@ const CELL_INDICES = 3;
const CELL_CONTENT = 0;
const CELL_FG = 1;
const STYLE_MASK = 0xfc000000;
const CM_MASK = 0x3000000;
const CM_RGB = 0x3000000;
const MAX_PLUGIN_HIGHLIGHT_SCAN_CHARS = 4_096;
const MAX_PLUGIN_HIGHLIGHT_MATCHES_PER_WRITE = 256;
@@ -1062,13 +1061,7 @@ export class KeywordHighlighter implements IDisposable {
const dataIndex = x * CELL_INDICES;
const content = internal._data[dataIndex + CELL_CONTENT];
const currentFg = internal._data[dataIndex + CELL_FG];
// A cell already carrying a PTY palette/truecolor foreground (starship
// directory segments, ANSI logs) must keep that color. Keyword RGB is
// only for the default foreground.
const maskedForContent = originals.mask[x] === 1 && originals.content[x] === content;
const ptyFg = maskedForContent ? originals.fg[x] : currentFg;
if ((ptyFg & CM_MASK) !== 0) continue;
if (!maskedForContent) {
if (!originals.mask[x] || originals.content[x] !== content) {
originals.fg[x] = currentFg;
originals.content[x] = content;
originals.mask[x] = 1;
@@ -34,7 +34,7 @@ test("local terminals stay out of hibernate and hidden-renderer paths", () => {
test("reconnect clears keyboard pairing before resetting protocol state", () => {
assert.match(
terminalSource,
/startNewSession = \(\) => \{[\s\S]*resetConnectionInputState\(host\);[\s\S]*resetKittyKeyboardModeStateForSession/,
/startNewSession = \(\) => \{[\s\S]*resetKittyConnectionInputState\(\);[\s\S]*resetKittyKeyboardModeStateForSession/,
);
assert.match(
runtimeSource,
@@ -42,7 +42,7 @@ test("reconnect clears keyboard pairing before resetting protocol state", () =>
);
assert.match(
runtimeSource,
/resetConnectionInputState: \(host\) => \{[\s\S]*clearKittyConnectionInputState\(\)/,
/resetKittyConnectionInputState: clearKittyConnectionInputState/,
);
assert.match(
runtimeSource,
@@ -985,12 +985,13 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
const { readFileSync } = await import("node:fs");
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
// The legacy workaround selected IME commits and short raw pastes (#3077).
// Host settings now gate that selection at the shared send boundary.
// Strict bastion prompts (QAX) treat one channel write as one keystroke and
// drop multi-character chunks, so IME commits and short raw pastes must be
// sent one character per write instead of one write per commit.
assert.match(source, /perCharacterWrites\?: boolean/);
assert.match(
source,
/getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)/,
/getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)/,
);
// Every chunk carries the same write options as the original single write.
const writeLoopIdx = source.indexOf("for (const chunk of getTextInputWireChunks(outData");
@@ -252,17 +252,6 @@ type TerminalBackendApi = {
// renderer hibernation. Weak ownership keeps negotiated keyboard state alive
// for that session without introducing a process-wide session registry.
const kittyKeyboardStates = createKittyKeyboardSessionStateStore();
// The same session-owned ref also keeps the connection's input choice alive
// when hibernation destroys xterm. Only a real reconnect refreshes this choice.
const inputCompatibilityStates = new WeakMap<object, boolean>();
// The stable session owner survives xterm disposal while its home renderer sleeps.
export const getTerminalPerCharacterInput = (owner: object): boolean | undefined =>
inputCompatibilityStates.get(owner);
export const restoreTerminalPerCharacterInput = (owner: object, enabled: boolean): void => {
inputCompatibilityStates.set(owner, enabled);
};
const resolveKittyKeyboardModeState = (
ctx: Pick<
@@ -318,10 +307,8 @@ export type XTermRuntime = {
* terminal reset or FIFO eviction.
*/
hasInlineImages: () => boolean;
/** Reset keyboard state and snapshot endpoint settings for a fresh connection. */
resetConnectionInputState: (host: Host) => void;
getPerCharacterInput: () => boolean;
restorePerCharacterInput: (enabled: boolean) => void;
/** Clear local/per-target keyboard state before reusing this runtime. */
resetKittyConnectionInputState: () => void;
/** Emit any owed releases before detaching or closing this renderer. */
flushKittyKeyboardReleases: () => void;
/** Transfer negotiated keyboard state across renderer attach handoffs. */
@@ -528,13 +515,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
const rendererType = settings?.rendererType ?? "auto";
const bridge = netcattyBridge.get();
const isLocalTerminalHost = ctx.host.protocol === "local";
// Capture the terminal endpoint's choice for this connection, never a chain hop.
const usesPerCharacterInput = (host: Host) =>
(host.protocol !== undefined && host.protocol !== "ssh") || host.moshEnabled === true
|| host.etEnabled === true || host.sshInputCompatibility === true;
let usePerCharacterInput = inputCompatibilityStates.get(ctx.terminalSettingsRef)
?? usesPerCharacterInput(ctx.host);
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
const windowsPty =
platform === "win32" && isLocalTerminalHost
? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
@@ -1345,9 +1325,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
lineDelayMs?: number;
pasteRequestId?: string;
/**
* Request the legacy IME/short-paste workaround reported in #3077.
* The SSH host's compatibility choice gates the final writes below.
* Bookkeeping still sees the whole payload once.
* Send plain text as one write per character. Strict bastion prompts
* (QAX) treat one channel write as a keystroke and drop multi-character
* chunks, so IME commits and short raw pastes must go out per
* character (#3077). Bookkeeping still sees the whole payload once.
*/
perCharacterWrites?: boolean;
},
@@ -1569,7 +1550,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
}
ctx.onOutputTriggerUserInputRef?.current?.(outData);
for (const chunk of getTextInputWireChunks(outData, usePerCharacterInput && options?.perCharacterWrites === true, (ctx.host.protocol === undefined || ctx.host.protocol === "ssh") && !ctx.host.moshEnabled && !ctx.host.etEnabled)) {
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
ctx.terminalBackend.writeToSession(id, chunk, {
sensitive,
serialEraseChar,
@@ -3159,7 +3140,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
broadcastLegacyDataPending = null;
}
// Raw multi-character onData is an unbracketed paste (or a committed
// composition): selected short plain text can use compatibility writes.
// composition): short plain text must reach strict bastions per character.
// Decide on the sanitized payload so removed zero-width characters cannot
// keep a qualifying paste above the split cap (#3138).
const sanitizedRawData = sanitizeTerminalInput(data);
@@ -3498,17 +3479,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
ligatures.apply(enabled);
},
hasInlineImages,
resetConnectionInputState: (host) => {
usePerCharacterInput = usesPerCharacterInput(host);
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
clearKittyConnectionInputState();
},
resetKittyConnectionInputState: clearKittyConnectionInputState,
flushKittyKeyboardReleases: clearKittyTransientInputState,
getPerCharacterInput: () => usePerCharacterInput,
restorePerCharacterInput: (enabled) => {
usePerCharacterInput = enabled;
restoreTerminalPerCharacterInput(ctx.terminalSettingsRef, enabled);
},
getKittyKeyboardModeState: () => snapshotKittyKeyboardModeState(kittyKeyboardMode),
restoreKittyKeyboardModeState: (state) => restoreKittyKeyboardModeState(
kittyKeyboardMode,
@@ -27,7 +27,7 @@ for (const prefix of ['fresh','empty-arrow','submit','paste-submit','interrupt',
const session={encoding, serialPort:{write(data: string | Buffer){for(const byte of Buffer.from(data)) { if(byte===127||byte===8) wire=wire.subarray(0,Math.max(0,wire.length-1)); else wire=Buffer.concat([wire,Buffer.from([byte])]);}}}};
bridge.init({sessions:new Map([['s',session]]),electronModule:{}});
const ctx={host:{protocol:'serial',id:'h',label:'h'},sessionId:'s',sessionRef:{current:'s'},statusRef:{current:'connected'},commandBufferRef:{current:''},onBroadcastInputRef:{current:null},isBroadcastEnabledRef:{current:false},terminalBackend:{writeToSession(id:string,data:string,opts: {serialEraseChar?: string; sensitive?: boolean}){bridge.writeToSession({}, {sessionId:id,data,...opts})}},serialByteOrientedBackspace:true,serialLocalEcho:false};
const env={usePerCharacterInput:true,...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
const env={...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
vm.runInNewContext(code,env); const {handleTerminalInputData:input,recordSerialSnippetInput:snippet}=env.api;
if(prefix==='empty-arrow') input('\x1b[D');
if(prefix==='submit') {input('旧');input('\x1b[D');input('\r');}
@@ -1,171 +0,0 @@
import assert from "node:assert/strict";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { buildSync } from "esbuild";
import { JSDOM } from "jsdom";
// Real xterm + runtime input callbacks; canvas measurement is stubbed because
// these assertions concern writes, not rendering or a native OS input method.
test("SSH host mode gates every interactive text entry and stays fixed until reconnect", async () => {
const root = fileURLToPath(new URL("../../..", import.meta.url));
const bundle = buildSync({
stdin: {
contents: 'export {createXTermRuntime} from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal"; export {dispatchTerminalLinePaste} from "./components/terminal/runtime/terminalUserPaste"; export {wakeTerminalFromHibernate} from "./components/terminal/terminalRuntimeMount";',
loader: "ts", resolveDir: root,
},
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
alias: {
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
},
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
Object.assign(dom.window, { structuredClone });
try {
dom.window.eval(`
window.matchMedia = () => ({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
window.ResizeObserver = window.IntersectionObserver = class {observe(){} unobserve(){} disconnect(){}};
HTMLCanvasElement.prototype.getContext = function(type) {
if (type !== '2d') return null;
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
{get:(target,key)=>key in target ? target[key] : ()=>{}});
};
`);
dom.window.eval(bundle);
const result = await dom.window.eval(`(async () => {
const {createXTermRuntime, DEFAULT_TERMINAL_SETTINGS, dispatchTerminalLinePaste, wakeTerminalFromHibernate} = runtimeTest;
const ref = current => ({current});
const panes = [];
const make = host => {
const el = document.createElement('div'); document.body.appendChild(el);
const writes = [], logical = [], broadcasts = [];
const context = {
container:el, host, getHost:()=>host,
fontFamilyId:'jetbrains-mono', resolvedFontFamily:'monospace', fontSize:14,
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,rendererType:'dom',fontLigatures:false,cursorBlink:false}),
terminalBackend:{writeToSession:(_,data)=>writes.push(data),resizeSession(){},openExternalAvailable:false},
sessionRef:ref(host.id),hotkeySchemeRef:ref('disabled'),disableTerminalFontZoomRef:ref(false),
keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),isBroadcastEnabledRef:ref(true),
onBroadcastInputRef:ref(data=>{broadcasts.push(data);return [];}),
onOutputTriggerUserInputRef:ref(data=>logical.push(data)),
sessionId:host.id,statusRef:ref('connected'),commandBufferRef:ref(''),requestSearchFocus(){},
kittyKeyboardProtocolEnabled:true,
};
const runtime = createXTermRuntime(context);
const pane={...runtime,writes,logical,broadcasts,context,el}; panes.push(pane); return pane;
};
const equal = (actual, expected, label) => {
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error(label+': '+JSON.stringify({actual,expected}));
};
const wait = () => new Promise(resolve=>setTimeout(resolve,20));
const check = (pane, text, split, label) => {
equal(pane.writes.splice(0),split ? Array.from(text) : [text],label);
equal(pane.logical.splice(0),[text],label+' logical payload once');
pane.broadcasts.length=0;
};
const hosts=[false,true].map((mode,index)=>({id:'mode-'+index,label:'Mode',hostname:'localhost',protocol:'ssh',sshInputCompatibility:mode}));
const metrics=[];
for (const host of hosts) {
const pane=make(host), term=pane.term, ta=term.textarea, mode=host.sshInputCompatibility;
for (const text of ['中文','👩🏽‍💻é','x'.repeat(32),'x'.repeat(33)]) {
term.paste(text); check(pane,text,mode && Array.from(text).length<=32,'raw paste '+mode);
}
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中国'}));
check(pane,'中国',mode,'insertText');
const longIme='中'.repeat(40);
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:longIme}));
check(pane,longIme,mode,'IME keeps its uncapped scope');
ta.dispatchEvent(new KeyboardEvent('keydown',{bubbles:true,keyCode:229,key:'Process'}));
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中文'}));
check(pane,'中文',mode,'IME 229 insertText');
ta.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true,data:''}));
ta.value='中文';
ta.dispatchEvent(new CompositionEvent('compositionupdate',{bubbles:true,data:'中文'}));
ta.dispatchEvent(new CompositionEvent('compositionend',{bubbles:true,data:'中文'}));
await wait(); check(pane,'中文',mode,'compositionend');
// xterm's composition fallback reaches onData independently of insertText.
ta.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true,data:''}));
term.input('中国',true); check(pane,'中国',mode,'composition onData fallback');
ta.dispatchEvent(new CompositionEvent('compositionend',{bubbles:true,data:''})); await wait();
pane.writes.length=0; pane.logical.length=0;
for (const data of ['\\x1b[A','\\x03','\\x03\\x04','\\x1b[0;;200u']) {
term.input(data,true); check(pane,data,false,'terminal control');
}
await new Promise(resolve=>term.write('\\x1b[?2004h',resolve));
term.paste('中文'); check(pane,'\\x1b[200~中文\\x1b[201~',false,'bracketed paste');
await new Promise(resolve=>term.write('\\x1b[?2004l',resolve));
dispatchTerminalLinePaste(term,'中文\\r',{lineDelayMs:1,sensitive:false});
equal(pane.writes.splice(0),['中文\\r'],'existing line-paste path stays intact');
pane.logical.length=0;
await new Promise(resolve=>term.write('\\x1b[>25u',resolve));
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中文'}));
const kittyWrites=pane.writes.splice(0);
equal(kittyWrites,['\\x1b[0;;20013:25991u'],'Kitty associated text stays in one transport write');
pane.logical.length=0;
await new Promise(resolve=>term.write('\\x1b[<u',resolve));
host.sshInputCompatibility=!mode;
term.paste('中文'); check(pane,'中文',mode,'save does not change active runtime');
// The app reconnects with the existing runtime, so exercise that reset too.
pane.resetConnectionInputState(host);
term.paste('中文'); check(pane,'中文',!mode,'existing runtime reconnect');
pane.dispose();
const reconnected=make(host);
reconnected.term.paste('中文'); check(reconnected,'中文',!mode,'reconnect reads saved mode');
metrics.push({mode,entryPoints:5,reconnectedMode:!mode});
}
// Exercise the actual wake entry that recreates xterm but only reattaches
// the existing backend. Its context contains the newly saved host record.
for (const mode of [false,true]) {
const connectedHost={id:'hibernate-'+mode,label:'Hibernate',hostname:'127.0.0.1',protocol:'ssh',sshInputCompatibility:mode};
const savedHost={...connectedHost,sshInputCompatibility:!mode};
let pane=make(connectedHost), reattached=0;
const wake = async old => {
old.dispose();
const refs={xtermRuntimeRef:ref(null),termRef:ref(null),fitAddonRef:ref(null),
serializeAddonRef:ref(null),searchAddonRef:ref(null),hasRuntimeRef:ref(false)};
const context={...old.context,host:savedHost};
const success=await wakeTerminalFromHibernate({
refs,runtimeContext:context,container:old.el,
getPayload:()=>({snapshot:'',alternateScreen:false,pendingBuffer:''}),
prepareWakeFlow:async()=>true,takePendingBuffer:()=>'',
stopHibernateDataListener(){},stopHibernateListeners(){},
reattachSession(){reattached++;},safeFit(){},resizeSession(){},forceSyncRenderAfterResize(){},
lastFittedSizeRef:ref(null),isBootActiveRef:ref(false),sessionId:connectedHost.id,updateStatus(){},
});
equal(success,true,'hibernate wake succeeds');
equal(context.sessionRef.current,connectedHost.id,'hibernate retains backend session');
const next={...refs.xtermRuntimeRef.current,context,el:old.el,
writes:old.writes,logical:old.logical,broadcasts:old.broadcasts};
panes.push(next); next.writes.length=0; next.logical.length=0; return next;
};
pane=await wake(pane);
pane.term.paste('中文');check(pane,'中文',mode,'wake retains connection-time mode');
pane.resetConnectionInputState(savedHost);
pane.term.paste('中文');check(pane,'中文',!mode,'real reconnect refreshes mode after wake');
pane=await wake(pane);
pane.term.paste('中文');check(pane,'中文',!mode,'next wake retains refreshed connection mode');
equal(reattached,2,'both wakes reattach the existing backend');
}
// The SSH-only field cannot change other transports' existing policy.
for (const protocol of ['local','telnet','serial','mosh','et','ssh-mosh','ssh-et']) {
const pane=make({id:'other-'+protocol,label:protocol,hostname:'127.0.0.1',
protocol:protocol.startsWith('ssh-')?'ssh':protocol,
moshEnabled:protocol==='ssh-mosh',etEnabled:protocol==='ssh-et',sshInputCompatibility:false});
pane.term.paste('中文'); check(pane,'中文',true,protocol+' retains legacy short paste');
pane.term.input('a\\rb',true); check(pane,'a\\rb',true,protocol+' retains legacy control-containing text');
}
for (const pane of panes) { try {pane.dispose();} catch {} }
return JSON.stringify(metrics);
})()`);
assert.deepEqual(JSON.parse(result), [
{ mode: false, entryPoints: 5, reconnectedMode: true },
{ mode: true, entryPoints: 5, reconnectedMode: false },
]);
} finally {
dom.window.close();
}
});
@@ -1,214 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import vm from "node:vm";
import { buildSync } from "esbuild";
import { JSDOM } from "jsdom";
import React, { useEffect } from "react";
import { act, create } from "react-test-renderer";
import ts from "typescript";
import type { Host } from "../../../domain/models";
import { resolveTerminalPopupHost } from "../../TerminalPopupPage";
import { useTerminalEffects } from "../useTerminalEffects";
import { isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture } from "../terminalHibernateRuntime";
const root = fileURLToPath(new URL("../../..", import.meta.url));
const terminalSource = readFileSync(path.join(root, "components/Terminal.tsx"), "utf8");
const homeStart = terminalSource.indexOf(" useEffect(() => {", terminalSource.indexOf("// Home renderer for AI observe popups"));
const homeEnd = terminalSource.indexOf(" const cleanupSession =", homeStart);
const homeEffect = ts.transpileModule(terminalSource.slice(homeStart, homeEnd), {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const cleanupEnd = terminalSource.indexOf(" const cleanupSessionRef =", homeEnd);
const popupCleanup = ts.transpileModule(`${terminalSource.slice(homeEnd, cleanupEnd)}
globalThis.cleanup = cleanupSession;`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 },
}).outputText;
const noop = () => {};
const ref = <T,>(current: T) => ({ current });
// Actual popup host resolution, home snapshot callbacks, attach boot effect,
// and real xterm writes. Unrelated visual effects are omitted by the hook seam.
test("attached popups retain the active mode across snapshots and return, including hibernated homes", async (t) => {
const bundle = buildSync({
stdin: { contents: 'export * from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal";', loader: "ts", resolveDir: root },
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
alias: {
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
},
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
}).outputFiles[0].text;
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
const keys = ["window", "document", "navigator", "IS_REACT_ACT_ENVIRONMENT"];
const previous = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, navigator: dom.window.navigator, IS_REACT_ACT_ENVIRONMENT: true })) {
Object.defineProperty(globalThis, key, { configurable: true, value });
}
Object.assign(dom.window, { structuredClone });
dom.window.eval(`
window.matchMedia=()=>({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
window.ResizeObserver=window.IntersectionObserver=class {observe(){} unobserve(){} disconnect(){}};
HTMLCanvasElement.prototype.getContext=function(type){
if(type!=='2d')return null;
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
{get:(target,key)=>key in target?target[key]:()=>{}});
};
`);
dom.window.eval(bundle);
const runtimeModule = dom.window.eval("runtimeTest");
try {
for (const mode of [false, true]) for (const hibernated of [false, true]) {
await t.test(`connected=${mode}, saved=${!mode}, home hibernated=${hibernated}`, async () => {
const sessionId = `popup-${mode}-${hibernated}`;
const connectedHost: Host = { id: "host", label: "Host", hostname: "127.0.0.1", port: 22379, username: "agent", protocol: "ssh" as const, tags: [], os: "linux" as const, sshInputCompatibility: mode };
const savedHost = { ...connectedHost, sshInputCompatibility: !mode };
const session = { id: sessionId, hostId: "host", hostLabel: "Host", hostname: "127.0.0.1", username: "agent", protocol: "ssh" as const, status: "connected" as const, createdAt: 1 };
const popupHost = resolveTerminalPopupHost({ title: "Host", startupCommand: "", parentSessionId: sessionId, sourceSession: session, attachSessionId: sessionId }, [savedHost]);
assert.equal(popupHost.sshInputCompatibility, !mode, "the popup sees the latest vault preference");
const settings = { ...runtimeModule.DEFAULT_TERMINAL_SETTINGS, rendererType: "dom", fontLigatures: false, cursorBlink: false };
const homeSettings = ref(settings);
const writes: string[] = [];
const makeContext = (host: Host, settingsRef = ref(settings)) => ({
container: document.body.appendChild(document.createElement("div")), host,
fontFamilyId: "jetbrains-mono", resolvedFontFamily: "monospace", fontSize: 14,
terminalTheme: { colors: { background: "#111111", foreground: "#eeeeee", cursor: "#ffffff", selection: "#444444" } },
terminalSettingsRef: settingsRef,
terminalBackend: { writeToSession: (_id: string, data: string) => writes.push(data), resizeSession: noop, openExternalAvailable: false },
sessionRef: ref<string | null>(sessionId), sessionId, statusRef: ref("connected"),
hotkeySchemeRef: ref("disabled"), disableTerminalFontZoomRef: ref(false), keyBindingsRef: ref([]),
onHotkeyActionRef: ref(undefined), isBroadcastEnabledRef: ref(false), onBroadcastInputRef: ref(undefined),
commandBufferRef: ref(""), requestSearchFocus: noop, kittyKeyboardProtocolEnabled: false,
});
const homeContext = makeContext(connectedHost, homeSettings);
const homeRuntime = runtimeModule.createXTermRuntime(homeContext);
const homeRuntimeRef = ref(hibernated ? null : homeRuntime);
const homeTermRef = ref(hibernated ? null : homeRuntime.term);
if (hibernated) homeRuntime.dispose();
let snapshotRequest: (payload: object) => Promise<void>;
let applySnapshot: (payload: object) => Promise<boolean>;
let snapshot: Record<string, unknown> = {};
const bridge = {
onTerminalSessionSnapshotRequest(callback: typeof snapshotRequest) { snapshotRequest = callback; return noop; },
onTerminalSessionApplySnapshot(callback: typeof applySnapshot) { applySnapshot = callback; return noop; },
respondTerminalSessionSnapshot(_request: string, text: string, keyboard: unknown, enabled: boolean, sensitive: boolean, cwd: string, title: string, perCharacterInput: boolean) {
snapshot = { success: true, snapshot: text, kittyKeyboardModeState: keyboard, kittyKeyboardProtocolEnabled: enabled, passwordPromptActive: sensitive, cwd, title, perCharacterInput };
},
};
const homeEnv = {
useEffect: (effect: () => void) => effect(), netcattyBridge: { get: () => bridge },
attachExistingSession: false, sessionId, terminalSettingsRef: homeSettings,
xtermRuntimeRef: homeRuntimeRef, termRef: homeTermRef,
serializeAddonRef: ref(hibernated ? null : homeRuntime.serializeAddon),
hibernatedRef: ref(hibernated), softHiddenRef: ref(false),
hibernateSnapshotRef: ref(""), hibernatePendingBufferRef: ref(""), hibernateViewportSnapshotRef: ref(""), hibernateScrollbackSnapshotRef: ref(""),
hibernateContextSnapshotRef: ref(""), hibernateContextViewportSnapshotRef: ref(""), hibernateContextScrollbackSnapshotRef: ref(""), hibernateAlternateScreenRef: ref(false),
kittyKeyboardProtocolEnabledForSession: false, passwordPromptActiveRef: ref(false), knownCwdRef: ref(null), terminalTitleRef: ref(null), pluginTerminalLifecycleRef: ref(null),
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, terminalCwdTracker: { setRendererCwd: noop },
flushPendingTerminalWritesBeforeHibernate: async () => true, resetTerminalLineTimestamps: noop,
applyAuthoritativeHibernateSnapshot: noop, logger: { warn: noop },
getTerminalPerCharacterInput: runtimeModule.getTerminalPerCharacterInput,
restoreTerminalPerCharacterInput: runtimeModule.restoreTerminalPerCharacterInput,
};
vm.runInNewContext(homeEffect, homeEnv);
const popupContext = makeContext(popupHost);
popupContext.sessionRef.current = null;
popupContext.statusRef.current = "connecting";
const values = {
...popupContext, host: popupHost, terminalSettings: settings,
containerRef: ref(popupContext.container), xtermRuntimeRef: ref(null), termRef: ref(null), fitAddonRef: ref(null), serializeAddonRef: ref(null), searchAddonRef: ref(null), hasRuntimeRef: ref(false),
attachExistingSession: true, attachAuthorization: "owned-popup", vaultInitialized: true,
CONNECTION_TIMEOUT: 30000, Error, XTERM_PERFORMANCE_CONFIG: {}, effectiveTheme: popupContext.terminalTheme,
effectiveFontSize: 14, effectiveFontWeight: 400, fontWeightFixupDoneRef: ref(false),
isVisible: true, isVisibleRef: ref(true), isFocused: true, inWorkspace: true, isLocalConnection: false, isNetworkDevice: false, isSerialConnection: false,
pluginDecorationRules: [], pluginTerminalLifecycle: new Proxy({}, { get: () => noop }),
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, keys: [], identities: [], serialConfig: {}, zmodem: {},
connectionLogBufferRef: ref({ reset: noop }), terminalOutputHistory: { clear: noop }, terminalDataCapturedRef: ref(false), hasConnectedRef: ref(false), pendingOutputScrollRef: ref(false), promptLineBreakStateRef: ref(null), terminalLogSanitizerRef: ref(null),
kittyKeyboardProtocolEnabledForSession: false, terminalCwdTracker: { getRendererCwd: () => null, setRendererCwd: noop }, passwordPromptActiveRef: ref(false),
createPromptLineBreakState: () => ({}), createReplaySafeTerminalLogSanitizer: () => ({}),
prepareInitialCwdIntent: noop, prepareRestoredReconnect: noop,
applyUserCursorPreference: noop, applyUserCursorWidthPreference: noop, shouldApplyUserCursorPreference: () => false,
terminalAltKeyOptions: () => ({}), shouldStartTerminalBackend: () => true,
createXTermRuntime: runtimeModule.createXTermRuntime,
terminalBackend: {
...popupContext.terminalBackend,
acquireSessionFlowPauseLease: async () => ({ waitForPause: async () => ({ success: true }), release: noop }),
requestSessionSnapshot: async () => { await snapshotRequest!({ sessionId, requestId: "request" }); return snapshot; },
rebindSessionOutput: async () => ({ success: true, previousWebContentsId: 10 }),
restoreSessionOutput: async () => ({ success: true }),
},
sessionStarters: { reattachSession: (term: { paste: (data: string) => void }) => { term.paste("中文"); return true; } },
setStatus: noop, setProgressLogs: noop, setShowLogs: noop, setIsCancelling: noop, setIsDisconnectedDialogDismissed: noop,
setError: (error: unknown) => { if (error) errors.push(error); },
updateStatus: (status: string) => { popupContext.statusRef.current = status; },
safeFit: noop, logger: { warn: noop, error: (...args: unknown[]) => errors.push(args) },
onPluginRuntimeCwdChange: noop, onOpenExternalError: noop, requestSearchFocus: noop,
requestPluginTerminalProviders: undefined, isPluginTerminalProviderAvailable: () => false,
useEffect: (effect: React.EffectCallback, dependencies: React.DependencyList) => {
useEffect(() => {
if (effect.toString().includes("bootAbort")) return effect();
// The injected hook owns this dependency list; unrelated effects stay inert.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, dependencies);
},
useLayoutEffect: noop,
};
const errors: unknown[] = [];
const defaultRefs = new Map<PropertyKey, ReturnType<typeof ref>>();
const context = new Proxy(values, { get: (target, key) => {
if (key in target) return target[key as keyof typeof target];
if (!defaultRefs.has(key)) defaultRefs.set(key, ref(null));
return defaultRefs.get(key);
} });
const Harness = () => { useTerminalEffects(context); return null; };
let renderer: ReturnType<typeof create>;
await act(async () => { renderer = create(React.createElement(Harness)); });
for (let attempt = 0; popupContext.statusRef.current !== "connected" && attempt < 30 && !errors.length; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
try {
assert.deepEqual(errors, [], "actual attach boot succeeded");
assert.equal(popupContext.sessionRef.current, sessionId, "attach retains the original SSH backend");
assert.deepEqual(writes.splice(0), mode ? ["中", "文"] : ["中文"], "first attached input must retain the connection mode");
assert.equal(snapshot.perCharacterInput, mode, "home snapshot exports captured mode, including false and no live xterm");
const popupRuntime = values.xtermRuntimeRef.current as typeof homeRuntime;
assert.equal(popupRuntime.getPerCharacterInput(), mode);
const cleanupEnv = {
...homeEnv, attachExistingSession: true, attachAuthorization: "owned-popup",
sessionRef: popupContext.sessionRef, xtermRuntimeRef: values.xtermRuntimeRef,
terminalSettingsRef: popupContext.terminalSettingsRef, termRef: values.termRef,
serializeAddonRef: values.serializeAddonRef, attachHomeWebContentsIdRef: ref(10),
disposeDataRef: ref(null), disposeExitRef: ref(null), disposeTelnetEchoModeRef: ref(null),
telnetLocalEchoRef: ref(false), sessionCleanupPromiseRef: ref(null),
isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture,
releaseTerminalFlowBeforeHibernate: noop,
terminalBackend: {
...values.terminalBackend,
applySessionSnapshot: async (_id: string, text: string, context: { perCharacterInput?: boolean }) => {
assert.equal(context.perCharacterInput, mode, "actual close exports the popup's active choice");
return { success: await applySnapshot!({ sessionId, snapshot: text, ...context }) };
},
},
};
vm.runInNewContext(popupCleanup, cleanupEnv);
await (cleanupEnv as typeof cleanupEnv & { cleanup: () => Promise<void> }).cleanup();
assert.equal(runtimeModule.getTerminalPerCharacterInput(homeSettings), mode, "actual popup close retains the home captured state");
const restoredHome = hibernated ? runtimeModule.createXTermRuntime({ ...homeContext, host: savedHost }) : homeRuntime;
restoredHome.term.paste("中文");
assert.deepEqual(writes.splice(0), mode ? ["中", "文"] : ["中文"], "home input after return and wake retains the active choice");
if (hibernated) restoredHome.dispose();
} finally {
values.hasConnectedRef.current = false;
await act(async () => { renderer!.unmount(); });
homeRuntime.dispose();
}
});
}
} finally {
dom.window.close();
for (const key of keys) { const descriptor = previous.get(key); if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
}
});
@@ -42,7 +42,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
);
assert.match(
runtimeSource,
/for \(const chunk of getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
);
assert.match(
runtimeSource,
@@ -127,7 +127,6 @@ for (const [protocol, lineMode, sensitive] of [
? { isWrapped: false, translateToString: () => liveLine } : undefined } },
};
const env = {
usePerCharacterInput: true,
...Object.assign({}, ...helpers), ...userPaste, ctx, term, crypto, isPluginHostProtocol: () => false, logger: { warn() {} },
netcattyBridge: { get: () => ({ onTerminalPasteWrite: (listener: typeof receiptListener) => { receiptListener = listener; return () => { receiptListener = undefined; }; } }) },
suppressNextTerminalDataBroadcast: false, handlingKittyBroadcast: false,
@@ -342,7 +341,6 @@ test("bypassed sensitive line paste keeps the paced fan-out with sourceSensitive
buffer: { active: { cursorX: 0, cursorY: 0, baseY: 0, getLine: () => undefined } },
};
const env = {
usePerCharacterInput: true,
...Object.assign({}, ...helpers), ...userPaste, ctx, term, crypto, isPluginHostProtocol: () => false, logger: { warn() {} },
netcattyBridge: { get: () => ({ onTerminalPasteWrite: (listener: typeof receiptListener) => { receiptListener = listener; return () => { receiptListener = undefined; }; } }) },
suppressNextTerminalDataBroadcast: false, handlingKittyBroadcast: false,
@@ -66,20 +66,4 @@ test("wire chunking honors the per-character request only for plain text", () =>
assert.deepEqual(getTextInputWireChunks("\x1b[200~ab\x1b[201~", true), [
"\x1b[200~ab\x1b[201~",
]);
});
test("compatibility preserves exact Unicode byte order and keeps terminal controls intact", () => {
for (const text of ["中文", "👩🏽‍💻", "e\u0301", "क्‍ष"]) {
assert.deepEqual(Buffer.from(getTextInputWireChunks(text, true).join("")), Buffer.from(text));
}
for (const data of ["\x03", "\x1b[A", "\x1b[200~中文\x1b[201~", "\x1b[0;;200u", "\x03\x04", "a\r\nb"]) {
assert.deepEqual(getTextInputWireChunks(data, true, true), [data]);
}
});
test("non-SSH callers retain legacy control-containing text chunking", () => {
assert.equal(shouldSplitRawPasteInputForWire("a\rb"), true);
assert.deepEqual(getTextInputWireChunks("a\rb", true), ["a", "\r", "b"]);
assert.deepEqual(getTextInputWireChunks("a\rb", true, true), ["a\rb"]);
});
});
@@ -1,16 +1,13 @@
/**
* Legacy workaround introduced by #3168 after #3077 reported that individual
* hanzi worked where multi-character input did not. The remote parser and
* other terminals' default send behavior were not verified. SSH sessions now
* select this workaround in their host settings.
* Strict bastion prompts (e.g. QAX/奇安信) treat one SSH channel write as a
* single keystroke and silently drop every multi-character chunk (#3077). IME
* commits and short raw pastes therefore have to leave the renderer as
* per-character writes, which is what typing produces and what Xshell does.
*/
/** Raw pastes longer than this keep the single-write behavior. */
export const MAX_RAW_PASTE_PER_CHARACTER_LENGTH = 32;
// Terminal control bytes are intentional: never fragment shortcuts or reports.
// eslint-disable-next-line no-control-regex
const TERMINAL_CONTROL = /[\x00-\x1f\x7f-\x9f]/;
const ESC = "\x1b";
/** True while the payload only carries plain text and no escape sequence. */
@@ -19,7 +16,7 @@ export const isPlainTerminalInputText = (data: string): boolean => !data.include
/** One chunk per Unicode code point so surrogate pairs stay intact. */
export const splitTextIntoCodePointWrites = (data: string): string[] => Array.from(data);
/** IME commits: any plain text with more than one character splits per code point. */
/** IME commits: any plain text with more than one character splits per glyph. */
export const shouldSplitImeTextInputForWire = (text: string): boolean =>
Array.from(text).length > 1 && isPlainTerminalInputText(text);
@@ -38,13 +35,11 @@ export const shouldSplitRawPasteInputForWire = (data: string): boolean => {
return true;
};
/** Escape sequences never split. SSH callers also preserve terminal controls. */
/** Chunks to write for one input payload; escape sequences are never split. */
export const getTextInputWireChunks = (
data: string,
perCharacterWrites: boolean,
preserveTerminalControls = false,
): string[] =>
perCharacterWrites && isPlainTerminalInputText(data)
&& !(preserveTerminalControls && TERMINAL_CONTROL.test(data))
? splitTextIntoCodePointWrites(data)
: [data];
@@ -716,11 +716,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
if (!snap?.success) {
throw new Error(snap?.error || "Failed to capture terminal snapshot");
}
// Restore the active connection policy before the attached session
// accepts input. Saved host changes still require a reconnect.
if (typeof snap.perCharacterInput === "boolean") {
runtime.restorePerCharacterInput(snap.perCharacterInput);
}
if (typeof snap.kittyKeyboardProtocolEnabled === "boolean") {
runtime.setKittyKeyboardProtocolEnabled(snap.kittyKeyboardProtocolEnabled);
}
@@ -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(
Loaded 100 of 360 files, more files were not shown because too many files have changed in this diff. Show more