mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
124
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
730fa643ba | ||
|
|
267bcbfb8b | ||
|
|
17c48b4d79 | ||
|
|
739cab2965 | ||
|
|
cecfa3a47e | ||
|
|
0a590b4ebc | ||
|
|
69c2cc8d1f | ||
|
|
ccfe41f39a | ||
|
|
d5cb8cfd3b | ||
|
|
c4433d4961 | ||
|
|
4213d8e672 | ||
|
|
264f1d983c | ||
|
|
35e3f9f99f | ||
|
|
4882e0501c | ||
|
|
c3a5f810ce | ||
|
|
59721f9717 | ||
|
|
dd41f9d8d0 | ||
|
|
6392ac9532 | ||
|
|
afe831bfc5 | ||
|
|
ab8764a317 | ||
|
|
7e72ae65ad | ||
|
|
f2e878858f | ||
|
|
2ff86c2217 | ||
|
|
5e6278cbc7 | ||
|
|
83952aac0d | ||
|
|
7ec6473112 | ||
|
|
17e6834f74 | ||
|
|
9ed8bf76dd | ||
|
|
91b8927c56 | ||
|
|
1e5e77fb9c | ||
|
|
f868221b5f | ||
|
|
fd45463d98 | ||
|
|
fdeeaf74bb | ||
|
|
ffabb9e9c7 | ||
|
|
733f44cfdc | ||
|
|
a8adeb86fe | ||
|
|
0c097a1260 | ||
|
|
5c9286775e | ||
|
|
618257e593 | ||
|
|
cde2be236e | ||
|
|
342941b618 | ||
|
|
8e4dd0c052 | ||
|
|
3a96cd2537 | ||
|
|
0a692dfc72 | ||
|
|
1d369bf322 | ||
|
|
0d77a2e56c | ||
|
|
d0e2c905cb | ||
|
|
5f06fd3cec | ||
|
|
d4ca123999 | ||
|
|
c11cde9bb2 | ||
|
|
3ac4772087 | ||
|
|
71d455d5de | ||
|
|
2838f90b03 | ||
|
|
ff3734d161 | ||
|
|
494470810a | ||
|
|
185eac3849 | ||
|
|
bf91894ab5 | ||
|
|
b39f7a3244 | ||
|
|
9af123e5d7 | ||
|
|
e5bd294cdd | ||
|
|
b48567d448 | ||
|
|
933bc93fc3 | ||
|
|
45ed193579 | ||
|
|
f471a17a75 | ||
|
|
b9293a7bb5 | ||
|
|
257660bcc1 | ||
|
|
eaf8c9a385 | ||
|
|
ff42d0c1b9 | ||
|
|
0adb7b822e | ||
|
|
311765eaa1 | ||
|
|
d2a8398b7a | ||
|
|
69307cee16 | ||
|
|
4445c292ea | ||
|
|
6fad272afe | ||
|
|
a7f8480da1 | ||
|
|
e7e57f2a52 | ||
|
|
ab2f95837e | ||
|
|
f3d04a1912 | ||
|
|
f4a6b205ad | ||
|
|
614b7c997a | ||
|
|
aaef0d2ee1 | ||
|
|
5128b51702 | ||
|
|
f5c951f9a1 | ||
|
|
8de33d9b5a | ||
|
|
c727cca643 | ||
|
|
ba4fab5679 | ||
|
|
27e5a10bfc | ||
|
|
3efabf2b0f | ||
|
|
74a2f99b08 | ||
|
|
80525d9b2d | ||
|
|
741ea4e9c7 | ||
|
|
4336f8fd53 | ||
|
|
aa8989c515 | ||
|
|
b6b45fc11a | ||
|
|
d5fd56ecdf | ||
|
|
d601d3d238 | ||
|
|
e20f2a9986 | ||
|
|
0256e7471d | ||
|
|
a4b1009c2a | ||
|
|
54b6f85b09 | ||
|
|
62798393c3 | ||
|
|
d382f7bd4f | ||
|
|
ff36dc7bce | ||
|
|
d45fb75370 | ||
|
|
5a1b50a407 | ||
|
|
0e7ba69edb | ||
|
|
babf97e2c1 | ||
|
|
622ffec2cd | ||
|
|
598de9cda0 | ||
|
|
3c1afa9b7d | ||
|
|
36d9a7bd7f | ||
|
|
c44633231a | ||
|
|
b1a13011a4 | ||
|
|
631ce6c1d5 | ||
|
|
3e9ac6a36b | ||
|
|
cbb1262955 | ||
|
|
7dcb52062f | ||
|
|
2f1cc57c54 | ||
|
|
c54df5e7d5 | ||
|
|
bb1d1ce8f7 | ||
|
|
e9e4ae576f | ||
|
|
2b0337bb55 | ||
|
|
dee7912f8c | ||
|
|
161c895240 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ 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 { PluginHostActionHost } from './components/plugins/PluginHostActionHost';
|
||||
import { PluginAuthenticationHost } from './components/plugins/PluginAuthenticationHost';
|
||||
import { MultilinePasteConfirmHost } from './components/terminal/MultilinePasteConfirmDialog';
|
||||
import { useExternalMcpGrantPersister } from './components/ai/useExternalMcpGrantPersister';
|
||||
@@ -86,10 +88,12 @@ function AppWithProviders({ settings, appLock }: { settings: SettingsState; appL
|
||||
<ScriptAutomationRoot />
|
||||
<ExternalMcpApprovalsHost />
|
||||
<PluginAuthenticationHost />
|
||||
<PluginHostInteractionHost />
|
||||
<MultilinePasteConfirmHost />
|
||||
<VaultPublisher>
|
||||
<SessionPublisher persistSessionRestore={!isPeerSessionWindow}>
|
||||
<App />
|
||||
<PluginHostActionHost />
|
||||
</SessionPublisher>
|
||||
</VaultPublisher>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -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>),
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -964,9 +964,53 @@ Highlight the focused split pane:
|
||||
'settings.shortcuts.category.terminal': 'Terminal',
|
||||
'settings.shortcuts.category.navigation': 'Navigation',
|
||||
'settings.shortcuts.category.app': 'App',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'settings.shortcuts.category.sftp': 'SFTP',
|
||||
|
||||
// Settings > Shortcuts -> key bindings
|
||||
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
|
||||
'settings.shortcuts.binding.next-tab': 'Next Tab',
|
||||
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
|
||||
'settings.shortcuts.binding.close-tab': 'Close Tab',
|
||||
'settings.shortcuts.binding.close-session': 'Close Session Pane',
|
||||
'settings.shortcuts.binding.new-tab': 'New Local Tab',
|
||||
'settings.shortcuts.binding.copy': 'Copy from Terminal',
|
||||
'settings.shortcuts.binding.paste': 'Paste to Terminal',
|
||||
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
|
||||
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
|
||||
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
|
||||
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
|
||||
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
|
||||
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
|
||||
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
|
||||
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
|
||||
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
|
||||
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
|
||||
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
|
||||
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
|
||||
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
|
||||
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
|
||||
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
|
||||
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
|
||||
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
|
||||
'settings.shortcuts.binding.new-workspace': 'New Workspace',
|
||||
'settings.shortcuts.binding.snippets': 'Open Snippets',
|
||||
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
|
||||
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
|
||||
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
|
||||
'settings.shortcuts.binding.open-settings': 'Open Settings',
|
||||
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
|
||||
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
|
||||
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
|
||||
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
|
||||
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
|
||||
'settings.shortcuts.binding.sftp-rename': 'Rename File',
|
||||
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
|
||||
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
|
||||
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
|
||||
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
|
||||
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
|
||||
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
|
||||
|
||||
// Context menus / common actions
|
||||
'action.newHost': 'New Host',
|
||||
'action.newSubfolder': 'New Subfolder',
|
||||
@@ -1191,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.',
|
||||
@@ -1221,5 +1380,20 @@ Highlight the focused split pane:
|
||||
'plugins.authentication.responseFailed': 'Could not send the authentication response. Try again or cancel the request.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'Could not send the authentication response: {message}. Try again or cancel the request.',
|
||||
'plugins.authentication.continue': 'Continue',
|
||||
'plugins.hostActions.select': 'Share selected host metadata',
|
||||
'plugins.hostActions.selectionNote': 'Share saved labels, addresses, usernames, protocols, groups and tags with this plugin. Group selection includes only the hosts selected now. Connecting requires a separate confirmation.',
|
||||
'plugins.hostActions.create': 'Review host draft',
|
||||
'plugins.hostActions.edit': 'Review host changes',
|
||||
'plugins.hostActions.connect': 'Confirm connection',
|
||||
'plugins.hostActions.fieldDiff': 'Changes to save',
|
||||
'plugins.hostActions.defaultsWarning': 'Saved connection defaults apply. Changing a group can change inherited authentication, proxy settings and scripts.',
|
||||
'plugins.hostActions.invalidDraft': 'Enter a valid host draft.',
|
||||
'plugins.hostActions.scriptsRun': 'Saved startup, connect or output scripts may run with this connection.',
|
||||
'plugins.hostActions.noScripts': 'No saved startup, connect or output scripts are configured for this host.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty will open this connection using the saved settings. This does not share session content with the plugin.',
|
||||
'plugins.hostActions.localExecution': 'Local execution: this opens a shell on this device.',
|
||||
'plugins.hostActions.managedUnsupported': 'Managed SSH config changes require the Vault host editor.',
|
||||
'plugins.hostActions.failed': 'The request expired, was revoked, or the host changed. Select the host again.',
|
||||
'plugins.hostActions.selectionLimit': 'Select up to 50 current hosts.',
|
||||
|
||||
};
|
||||
@@ -90,6 +90,15 @@ export const esAiMessages: Messages = {
|
||||
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
|
||||
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
|
||||
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
|
||||
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
|
||||
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
|
||||
'ai.providers.headers.name': 'Nombre de la cabecera',
|
||||
'ai.providers.headers.value': 'Valor de la cabecera',
|
||||
'ai.providers.headers.add': 'Agregar cabecera',
|
||||
'ai.providers.headers.remove': 'Quitar cabecera',
|
||||
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
|
||||
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
|
||||
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
|
||||
|
||||
// AI Codex
|
||||
'ai.codex': 'Codex',
|
||||
@@ -280,6 +289,14 @@ export const esAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Verificar',
|
||||
'ai.opencode.resetPath': 'Restablecer',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
|
||||
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
|
||||
|
||||
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -694,6 +711,9 @@ export const esAiMessages: Messages = {
|
||||
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
|
||||
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
|
||||
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
|
||||
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
|
||||
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
|
||||
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
|
||||
'terminal.layer.hostTree.search': 'Buscar hosts...',
|
||||
'terminal.layer.hostTree.searchButton': 'Buscar',
|
||||
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
|
||||
|
||||
@@ -84,6 +84,32 @@ export const esCoreMessages: Messages = {
|
||||
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
|
||||
'credentials.protectionUnavailable.action': 'Abrir configuración',
|
||||
|
||||
// App Lock
|
||||
'appLock.title': 'Netcatty está bloqueado',
|
||||
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
|
||||
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
|
||||
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
|
||||
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
|
||||
'appLock.passwordLabel': 'Contraseña de bloqueo',
|
||||
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
|
||||
'appLock.unlock': 'Desbloquear',
|
||||
'appLock.unlocking': 'Desbloqueando...',
|
||||
'appLock.usePassword': 'Usar contraseña',
|
||||
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
|
||||
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
|
||||
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
|
||||
'appLock.systemUnlock.preparing': 'Preparando {label}...',
|
||||
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
|
||||
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
|
||||
'appLock.logoLabel': 'Netcatty',
|
||||
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
|
||||
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
|
||||
'appLock.reset.cancel': 'Cancelar',
|
||||
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
|
||||
'appLock.reset.resetting': 'Restableciendo...',
|
||||
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
|
||||
'topTabs.lockApp': 'Bloquear Netcatty',
|
||||
|
||||
// Settings shell
|
||||
'settings.title': 'Configuración',
|
||||
'settings.tab.application': 'Aplicación',
|
||||
@@ -119,6 +145,65 @@ export const esCoreMessages: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
|
||||
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
|
||||
|
||||
// Settings > System > App Lock
|
||||
'settings.appLock.title': 'Bloqueo de la aplicación',
|
||||
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
|
||||
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
|
||||
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
|
||||
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
|
||||
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
|
||||
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
|
||||
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
|
||||
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.disabling': 'Desactivando...',
|
||||
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
|
||||
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
|
||||
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
|
||||
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
|
||||
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
|
||||
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
|
||||
'settings.appLock.timeout': 'Tiempo de inactividad',
|
||||
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
|
||||
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
|
||||
'settings.appLock.timeout.1': '1 minuto',
|
||||
'settings.appLock.timeout.5': '5 minutos',
|
||||
'settings.appLock.timeout.15': '15 minutos',
|
||||
'settings.appLock.timeout.30': '30 minutos',
|
||||
'settings.appLock.timeout.60': '60 minutos',
|
||||
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
|
||||
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
|
||||
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
|
||||
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
|
||||
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
|
||||
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
|
||||
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
|
||||
'settings.appLock.currentPassword': 'Contraseña actual',
|
||||
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
|
||||
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
|
||||
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
|
||||
'settings.appLock.newPassword': 'Nueva contraseña',
|
||||
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
|
||||
'settings.appLock.confirmPassword': 'Confirmar contraseña',
|
||||
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
|
||||
'settings.appLock.savePassword': 'Definir y activar',
|
||||
'settings.appLock.savingPassword': 'Guardando...',
|
||||
'settings.appLock.passwordSet': 'Contraseña configurada',
|
||||
'settings.appLock.replacePassword': 'Reemplazar contraseña',
|
||||
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
|
||||
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
|
||||
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
|
||||
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
|
||||
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
|
||||
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
|
||||
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
|
||||
|
||||
// Settings > System > Auto Launch
|
||||
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
|
||||
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
|
||||
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
|
||||
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
|
||||
|
||||
// Settings > System > Network Proxy
|
||||
'settings.system.networkProxy.title': 'Proxy de red',
|
||||
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
|
||||
@@ -1150,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}.',
|
||||
@@ -1180,5 +1380,20 @@ Resalta el panel dividido enfocado:
|
||||
'plugins.authentication.responseFailed': 'No se pudo enviar la respuesta de autenticación. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'No se pudo enviar la respuesta de autenticación: {message}. Inténtalo de nuevo o cancela la solicitud.',
|
||||
'plugins.authentication.continue': 'Continuar',
|
||||
'plugins.hostActions.select': 'Compartir metadatos de los equipos seleccionados',
|
||||
'plugins.hostActions.selectionNote': 'Comparte nombres, direcciones, usuarios, protocolos, grupos y etiquetas guardados con este complemento. Los grupos solo incluyen los equipos seleccionados ahora. Conectar requiere otra confirmación.',
|
||||
'plugins.hostActions.create': 'Revisar borrador de equipo',
|
||||
'plugins.hostActions.edit': 'Revisar cambios del equipo',
|
||||
'plugins.hostActions.connect': 'Confirmar conexión',
|
||||
'plugins.hostActions.fieldDiff': 'Cambios que se guardarán',
|
||||
'plugins.hostActions.defaultsWarning': 'Se aplican los ajustes de conexión guardados. Cambiar el grupo puede cambiar la autenticación, el proxy y los scripts heredados.',
|
||||
'plugins.hostActions.invalidDraft': 'Introduce un borrador de equipo válido.',
|
||||
'plugins.hostActions.scriptsRun': 'Esta conexión puede ejecutar scripts guardados de inicio, conexión o salida.',
|
||||
'plugins.hostActions.noScripts': 'Este equipo no tiene scripts guardados de inicio, conexión o salida.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty abrirá la conexión con los ajustes guardados. Esto no comparte el contenido de la sesión con el complemento.',
|
||||
'plugins.hostActions.localExecution': 'Ejecución local: esto abre una shell en este dispositivo.',
|
||||
'plugins.hostActions.managedUnsupported': 'Los cambios de configuración SSH administrada requieren el editor de equipos de Vault.',
|
||||
'plugins.hostActions.failed': 'La solicitud caducó, se revocó o el equipo cambió. Selecciona el equipo de nuevo.',
|
||||
'plugins.hostActions.selectionLimit': 'Selecciona hasta 50 equipos actuales.',
|
||||
|
||||
};
|
||||
@@ -33,6 +33,9 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
|
||||
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
|
||||
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
|
||||
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
|
||||
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
|
||||
|
||||
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
|
||||
@@ -212,6 +215,20 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
'systemManager.k8s.status.Running': 'En ejecución',
|
||||
'systemManager.k8s.status.Pending': 'Pendiente',
|
||||
'systemManager.k8s.status.Completed': 'Completado',
|
||||
'systemManager.k8s.status.Succeeded': 'Con éxito',
|
||||
'systemManager.k8s.status.Failed': 'Fallido',
|
||||
'systemManager.k8s.status.Unknown': 'Desconocido',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
|
||||
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
|
||||
'systemManager.k8s.status.Terminating': 'Terminando',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
|
||||
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Desalojado',
|
||||
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -219,6 +219,7 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
|
||||
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
|
||||
'terminal.progress.disconnected': 'Desconectado',
|
||||
'terminal.progress.connected': 'Conectado',
|
||||
'terminal.progress.cancelling': 'Cancelando...',
|
||||
'terminal.progress.startOver': 'Empezar de nuevo',
|
||||
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
|
||||
@@ -675,6 +676,45 @@ export const esTerminalMessages: Messages = {
|
||||
'keychain.keyDialog.editTitle': 'Editar clave',
|
||||
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
|
||||
'keychain.keyDialog.updateKey': 'Actualizar clave',
|
||||
'keychain.generate.standardKeys': 'Claves estándar',
|
||||
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
|
||||
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
|
||||
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
|
||||
'keychain.generate.generating': 'Generando…',
|
||||
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
|
||||
'keychain.generate.resident': 'Guardar las credenciales en la llave',
|
||||
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
|
||||
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
|
||||
|
||||
// FIDO2 security key prompts and recovery states
|
||||
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
|
||||
'fido.prompt.details': 'Detalles de la solicitud',
|
||||
'fido.prompt.showPin': 'Mostrar PIN',
|
||||
'fido.prompt.hidePin': 'Ocultar PIN',
|
||||
'fido.prompt.verifying': 'Verificando…',
|
||||
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
|
||||
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
|
||||
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
|
||||
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
|
||||
'fido.prompt.pinLabel': 'PIN',
|
||||
'fido.prompt.touchWaiting': 'Esperando tu llave…',
|
||||
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
|
||||
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
|
||||
'fido.prompt.touchDone': 'Ya la toqué',
|
||||
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
|
||||
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
|
||||
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
|
||||
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
|
||||
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
|
||||
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
|
||||
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
|
||||
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
|
||||
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
|
||||
'fido.recovery.retry': 'Reintentar verificación',
|
||||
'fido.recovery.usePassword': 'Usar la contraseña configurada',
|
||||
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
|
||||
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
|
||||
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
|
||||
|
||||
// Tabs
|
||||
'tabs.closeSessionAria': 'Cerrar sesión',
|
||||
|
||||
@@ -1044,6 +1044,18 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.addTag': 'Agregar una etiqueta...',
|
||||
'hostDetails.createTag': 'Crear etiqueta',
|
||||
'hostDetails.createGroup': 'Crear grupo',
|
||||
|
||||
// Host connection test
|
||||
'hostConnectionTest.title': 'Probar conexión',
|
||||
'hostConnectionTest.test': 'Probar',
|
||||
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
|
||||
'hostConnectionTest.success': 'Conexión correcta',
|
||||
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
|
||||
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
|
||||
'hostConnectionTest.failed': 'No se pudo conectar.',
|
||||
'hostConnectionTest.retry': 'Reintentar',
|
||||
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
|
||||
|
||||
// Entradas de shell local (CMD) en la bóveda
|
||||
'localShell.subtitle': 'Shell local',
|
||||
'localShell.panel.title': 'Nueva shell local',
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
|
||||
import en from "./en.ts";
|
||||
import es from "./es.ts";
|
||||
import ru from "./ru.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
// Locales that are not at full key parity with `en` yet. They are tracked here
|
||||
// so the remaining debt stays visible instead of silently rendering in English
|
||||
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
|
||||
// Move a locale out of this list once it covers every `en` key.
|
||||
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
|
||||
|
||||
const hasKey = (messages: Record<string, string>, key: string) =>
|
||||
Object.hasOwn(messages, key);
|
||||
|
||||
const interpolationTokens = (value: string) =>
|
||||
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
|
||||
.map((match) => match[0].replace(/[{}]/g, "").trim())
|
||||
.sort();
|
||||
|
||||
test("es defines every message key that en defines", () => {
|
||||
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`es is missing ${missing.length} message keys`,
|
||||
);
|
||||
});
|
||||
|
||||
test("es defines no message keys that en does not define", () => {
|
||||
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
|
||||
|
||||
assert.deepEqual(
|
||||
extra,
|
||||
[],
|
||||
`es defines ${extra.length} keys that no longer exist in en`,
|
||||
);
|
||||
});
|
||||
|
||||
test("es keeps the same interpolation placeholders as en", () => {
|
||||
const mismatched: string[] = [];
|
||||
|
||||
for (const key of Object.keys(es)) {
|
||||
if (!hasKey(en, key)) continue;
|
||||
|
||||
const expected = interpolationTokens(en[key] ?? "").join(",");
|
||||
const actual = interpolationTokens(es[key] ?? "").join(",");
|
||||
if (expected !== actual) mismatched.push(key);
|
||||
}
|
||||
|
||||
assert.deepEqual(
|
||||
mismatched,
|
||||
[],
|
||||
`es changes interpolation placeholders for ${mismatched.length} keys`,
|
||||
);
|
||||
});
|
||||
|
||||
test("every default shortcut has a label in en and es", () => {
|
||||
const locales = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "es", messages: es },
|
||||
];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = DEFAULT_KEY_BINDINGS.map(
|
||||
(binding) => `settings.shortcuts.binding.${binding.id}`,
|
||||
).filter((key) => !hasKey(locale.messages, key));
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale.name} is missing ${missing.length} shortcut labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
|
||||
// SettingsShortcutsTab falls back to `binding.label` only while the locale
|
||||
// resolves the key to itself. Once `en` defines the key, the locale becomes
|
||||
// the rendered source, so the two must not drift apart.
|
||||
for (const binding of DEFAULT_KEY_BINDINGS) {
|
||||
const key = `settings.shortcuts.binding.${binding.id}`;
|
||||
|
||||
assert.equal(
|
||||
en[key],
|
||||
binding.label,
|
||||
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("every locale outside the pending list is at full parity with en", () => {
|
||||
const locales = [
|
||||
{ name: "es", messages: es },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
];
|
||||
|
||||
for (const locale of locales) {
|
||||
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const missing = Object.keys(en).filter(
|
||||
(key) => !hasKey(locale.messages, key),
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`${locale.name} is missing ${missing.length} message keys`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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}.',
|
||||
@@ -1160,4 +1275,19 @@ export const ruCoreMessages: Messages = {
|
||||
'plugins.authentication.responseFailed': 'Не удалось отправить ответ аутентификации. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.responseFailedWithMessage': 'Не удалось отправить ответ аутентификации: {message}. Повторите попытку или отмените запрос.',
|
||||
'plugins.authentication.continue': 'Продолжить',
|
||||
'plugins.hostActions.select': 'Передать метаданные выбранных хостов',
|
||||
'plugins.hostActions.selectionNote': 'Плагину будут переданы сохранённые имена, адреса, пользователи, протоколы, группы и теги. Группа включает только выбранные сейчас хосты. Подключение требует отдельного подтверждения.',
|
||||
'plugins.hostActions.create': 'Проверить черновик хоста',
|
||||
'plugins.hostActions.edit': 'Проверить изменения хоста',
|
||||
'plugins.hostActions.connect': 'Подтвердить подключение',
|
||||
'plugins.hostActions.fieldDiff': 'Изменения для сохранения',
|
||||
'plugins.hostActions.defaultsWarning': 'Применяются сохранённые настройки подключения. Смена группы может изменить унаследованные настройки входа, прокси и скрипты.',
|
||||
'plugins.hostActions.invalidDraft': 'Введите корректные данные хоста.',
|
||||
'plugins.hostActions.scriptsRun': 'При подключении могут выполняться сохранённые скрипты запуска, подключения или вывода.',
|
||||
'plugins.hostActions.noScripts': 'Для этого хоста не настроены сохранённые скрипты запуска, подключения или вывода.',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty откроет подключение с сохранёнными настройками. Содержимое сеанса не передаётся плагину.',
|
||||
'plugins.hostActions.localExecution': 'Локальное выполнение: откроется оболочка на этом устройстве.',
|
||||
'plugins.hostActions.managedUnsupported': 'Изменяйте управляемые настройки SSH в редакторе хостов Vault.',
|
||||
'plugins.hostActions.failed': 'Запрос истёк, был отозван или хост изменился. Выберите хост заново.',
|
||||
'plugins.hostActions.selectionLimit': 'Выберите не более 50 текущих хостов.',
|
||||
};
|
||||
@@ -18,12 +18,19 @@ const LOCALIZED_SETTINGS_LOCALES = [
|
||||
|
||||
const APP_LOCK_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "es", messages: es },
|
||||
{ name: "ru", messages: ru },
|
||||
{ name: "zh-CN", messages: zhCN },
|
||||
{ name: "zh-TW", messages: zhTW },
|
||||
];
|
||||
|
||||
const ALL_SETTINGS_LOCALES = [
|
||||
{ name: "en", messages: en },
|
||||
...LOCALIZED_SETTINGS_LOCALES,
|
||||
];
|
||||
|
||||
test("localized settings include names for every default shortcut", () => {
|
||||
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
|
||||
for (const locale of ALL_SETTINGS_LOCALES) {
|
||||
const missing = DEFAULT_KEY_BINDINGS
|
||||
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
|
||||
.filter((key) => !locale.messages[key]);
|
||||
|
||||
@@ -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} 上下文中打开此设置。',
|
||||
@@ -1180,5 +1295,20 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'plugins.authentication.responseFailed': '无法发送认证响应。请重试或取消此请求。',
|
||||
'plugins.authentication.responseFailedWithMessage': '无法发送认证响应:{message}。请重试或取消此请求。',
|
||||
'plugins.authentication.continue': '继续',
|
||||
'plugins.hostActions.select': '分享所选主机的元数据',
|
||||
'plugins.hostActions.selectionNote': '向此插件分享已保存的名称、地址、用户名、协议、分组和标签。选择分组仅包含本次选中的成员。连接主机需要单独确认。',
|
||||
'plugins.hostActions.create': '预览主机草稿',
|
||||
'plugins.hostActions.edit': '预览主机修改',
|
||||
'plugins.hostActions.connect': '确认连接',
|
||||
'plugins.hostActions.fieldDiff': '即将保存的修改',
|
||||
'plugins.hostActions.defaultsWarning': '将应用已保存的连接默认设置。修改分组可能改变继承的认证、代理和脚本设置。',
|
||||
'plugins.hostActions.invalidDraft': '请输入有效的主机草稿。',
|
||||
'plugins.hostActions.scriptsRun': '此连接可能运行已保存的启动、连接或输出触发脚本。',
|
||||
'plugins.hostActions.noScripts': '此主机未配置已保存的启动、连接或输出触发脚本。',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty 将使用已保存的设置打开连接。这不会向插件分享会话内容。',
|
||||
'plugins.hostActions.localExecution': '本地执行:这将在此设备上打开一个 Shell。',
|
||||
'plugins.hostActions.managedUnsupported': '受管理的 SSH 配置变更需在 Vault 主机编辑器中完成。',
|
||||
'plugins.hostActions.failed': '请求已过期、被撤销或主机已改变。请重新选择主机。',
|
||||
'plugins.hostActions.selectionLimit': '最多选择 50 个当前主机。',
|
||||
|
||||
};
|
||||
@@ -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': '請按下組合鍵',
|
||||
@@ -1183,4 +1298,19 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'plugins.authentication.responseFailed': '無法送出驗證回應。請重試或取消此要求。',
|
||||
'plugins.authentication.responseFailedWithMessage': '無法送出驗證回應:{message}。請重試或取消此要求。',
|
||||
'plugins.authentication.continue': '繼續',
|
||||
'plugins.hostActions.select': '分享所選主機的中繼資料',
|
||||
'plugins.hostActions.selectionNote': '向此外掛分享已儲存的名稱、位址、使用者名稱、協定、群組和標籤。選擇群組僅包含本次選取的成員。連線需要另外確認。',
|
||||
'plugins.hostActions.create': '預覽主機草稿',
|
||||
'plugins.hostActions.edit': '預覽主機修改',
|
||||
'plugins.hostActions.connect': '確認連線',
|
||||
'plugins.hostActions.fieldDiff': '即將儲存的修改',
|
||||
'plugins.hostActions.defaultsWarning': '將套用已儲存的連線預設設定。修改群組可能改變繼承的驗證、代理和指令碼設定。',
|
||||
'plugins.hostActions.invalidDraft': '請輸入有效的主機草稿。',
|
||||
'plugins.hostActions.scriptsRun': '此連線可能執行已儲存的啟動、連線或輸出觸發指令碼。',
|
||||
'plugins.hostActions.noScripts': '此主機未設定已儲存的啟動、連線或輸出觸發指令碼。',
|
||||
'plugins.hostActions.connectionNote': 'Netcatty 將使用已儲存的設定開啟連線。這不會向外掛分享工作階段內容。',
|
||||
'plugins.hostActions.localExecution': '本機執行:這會在此裝置上開啟 Shell。',
|
||||
'plugins.hostActions.managedUnsupported': '受管理的 SSH 設定變更需在 Vault 主機編輯器中完成。',
|
||||
'plugins.hostActions.failed': '請求已過期、被撤銷或主機已變更。請重新選擇主機。',
|
||||
'plugins.hostActions.selectionLimit': '最多選擇 50 部目前的主機。',
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { decideHiddenTabKeepCountAction } from "../../domain/terminalHibernate.ts";
|
||||
import { retainLeavingTabAfterEviction } from "./hiddenTabEviction.ts";
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
test("a refused eviction tries the next candidate, then full-hibernates", async () => {
|
||||
const attempts: string[] = [];
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["images", "plain"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
attempts.push(sessionId);
|
||||
return sessionId === "plain";
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ["images", "plain"]);
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
|
||||
const blocked: string[] = [];
|
||||
const blockedOutcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["images", "also-images"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => blockedOutcomes.push("soft-hide"),
|
||||
fullHibernate: () => blockedOutcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
blocked.push(sessionId);
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(blocked, ["images", "also-images"]);
|
||||
assert.deepEqual(blockedOutcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("hibernate failure after the soft-hidden marker drops does not retain the leaving tab", async () => {
|
||||
const attempts: string[] = [];
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
attempts.push(sessionId);
|
||||
await Promise.resolve();
|
||||
return false;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ["victim"]);
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
|
||||
const recovered: string[] = [];
|
||||
const recoveredOutcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim", "next"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => recoveredOutcomes.push("soft-hide"),
|
||||
fullHibernate: () => recoveredOutcomes.push("full-hibernate"),
|
||||
evict: async (sessionId) => {
|
||||
recovered.push(sessionId);
|
||||
await Promise.resolve();
|
||||
return sessionId === "next";
|
||||
},
|
||||
});
|
||||
assert.deepEqual(recovered, ["victim", "next"]);
|
||||
assert.deepEqual(recoveredOutcomes, ["soft-hide"]);
|
||||
});
|
||||
|
||||
test("a live keep count that still fits after eviction soft-hides the leaving tab", async () => {
|
||||
let keepCount = 0;
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 2;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a keep count dropped to zero during eviction full-hibernates the leaving tab", async () => {
|
||||
let keepCount = 2;
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 0;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a keep count that no longer fits after eviction full-hibernates the leaving tab", async () => {
|
||||
const sessions = ["victim", "other", "leaving"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
let keepCount = 2;
|
||||
const outcomes: string[] = [];
|
||||
try {
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount,
|
||||
resolveKeepCount: () => keepCount,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => {
|
||||
keepCount = 1;
|
||||
await Promise.resolve();
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(outcomes, ["full-hibernate"]);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("leaving"), false);
|
||||
} finally {
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("a leaving tab that no longer wants retention is not soft-hidden or hibernated", async () => {
|
||||
const outcomes: string[] = [];
|
||||
await retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => false,
|
||||
softHide: () => outcomes.push("soft-hide"),
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: async () => true,
|
||||
});
|
||||
assert.deepEqual(outcomes, []);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("a pending reservation keeps a second tab from seeing spare capacity", async () => {
|
||||
const sessions = ["victim", "other", "leaving"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
});
|
||||
|
||||
const outcomes: string[] = [];
|
||||
const pending = retainLeavingTabAfterEviction({
|
||||
sessionId: "leaving",
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => {
|
||||
terminalHiddenRendererStore.markSoftHidden("leaving");
|
||||
outcomes.push("soft-hide");
|
||||
},
|
||||
fullHibernate: () => outcomes.push("full-hibernate"),
|
||||
evict: (sessionId) => terminalHiddenRendererStore.evictSoftHidden(sessionId),
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), false);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 1);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
assert.deepEqual(decideHiddenTabKeepCountAction({
|
||||
keepCount: 2,
|
||||
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
|
||||
sessionId: "second",
|
||||
candidatesOldestFirst: terminalHiddenRendererStore.listSoftHiddenOldestFirst(),
|
||||
}), { action: "await-eviction", candidates: ["other"] });
|
||||
|
||||
terminalHiddenRendererStore.settleEviction("victim", true);
|
||||
await pending;
|
||||
assert.deepEqual(outcomes, ["soft-hide"]);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
terminalHiddenRendererStore.settleEviction("victim", false);
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
await pending.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("two leaving tabs share one freed slot and stay within keep-count", async () => {
|
||||
const sessions = ["victim", "other", "leaving-a", "leaving-b"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
queueMicrotask(() => {
|
||||
terminalHiddenRendererStore.settleEviction("victim", true);
|
||||
});
|
||||
});
|
||||
|
||||
const retained: string[] = [];
|
||||
const hibernated: string[] = [];
|
||||
const retain = (sessionId: string) => retainLeavingTabAfterEviction({
|
||||
sessionId,
|
||||
candidates: ["victim"],
|
||||
keepCount: 2,
|
||||
stillWantsRetention: () => true,
|
||||
softHide: () => {
|
||||
terminalHiddenRendererStore.markSoftHidden(sessionId);
|
||||
retained.push(sessionId);
|
||||
},
|
||||
fullHibernate: () => hibernated.push(sessionId),
|
||||
});
|
||||
|
||||
try {
|
||||
await Promise.all([retain("leaving-a"), retain("leaving-b")]);
|
||||
assert.equal(retained.length, 1);
|
||||
assert.equal(hibernated.length, 1);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenCount(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { terminalHiddenRendererStore } from "./terminalHiddenRendererStore.ts";
|
||||
|
||||
/**
|
||||
* Walk keep-count victims until one hibernate completes, then retain the
|
||||
* leaving tab only if its reserved slot still fits.
|
||||
*
|
||||
* The reservation is taken before the first await. Clearing a victim marker
|
||||
* during wake would otherwise look like spare capacity to a second hidden-tab
|
||||
* timer, and both tabs would soft-hide once the same eviction resolved.
|
||||
*/
|
||||
export async function retainLeavingTabAfterEviction(input: {
|
||||
sessionId: string;
|
||||
candidates: readonly string[];
|
||||
/** Count captured when this leave started. Used only if resolveKeepCount is absent. */
|
||||
keepCount: number;
|
||||
/**
|
||||
* Live keep-count read after eviction settles. Settings can change while the
|
||||
* victim drains or serializes; retaining against the captured count would
|
||||
* soft-hide a tab the user no longer wants kept.
|
||||
*/
|
||||
resolveKeepCount?: () => number;
|
||||
stillWantsRetention: () => boolean;
|
||||
softHide: () => void;
|
||||
fullHibernate: () => void;
|
||||
evict?: (sessionId: string) => Promise<boolean> | boolean;
|
||||
}): Promise<void> {
|
||||
const token = terminalHiddenRendererStore.reserveLeavingSlot(input.sessionId);
|
||||
try {
|
||||
const evict = input.evict ?? ((sessionId: string) => (
|
||||
terminalHiddenRendererStore.evictSoftHidden(sessionId)
|
||||
));
|
||||
let completed = false;
|
||||
for (const candidate of input.candidates) {
|
||||
if (!candidate || candidate === input.sessionId) continue;
|
||||
if (await evict(candidate)) {
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!input.stillWantsRetention()) return;
|
||||
const keepCount = input.resolveKeepCount?.() ?? input.keepCount;
|
||||
if (
|
||||
completed
|
||||
&& terminalHiddenRendererStore.leavingSlotStillFits(input.sessionId, keepCount)
|
||||
) {
|
||||
input.softHide();
|
||||
return;
|
||||
}
|
||||
input.fullHibernate();
|
||||
} finally {
|
||||
terminalHiddenRendererStore.releaseLeavingSlot(input.sessionId, token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { executePluginHostAction, type PluginHostAction, type PluginHostActionVault } from './pluginHostActionExecution';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { Host, ManagedSource } from '../../domain/models';
|
||||
|
||||
const synthetic: Host = { id: 'synthetic', label: 'Synthetic', hostname: 'synthetic.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-only', startupCommand: 'synthetic-script' };
|
||||
|
||||
function fixture() {
|
||||
const resources = new PluginHostActionResources();
|
||||
let active = true, authorized = 0, connections = 0, writes = 0;
|
||||
let beforeRead: (() => void) | undefined;
|
||||
let beforeCommit: (() => void) | undefined;
|
||||
let duringAuthorize: (() => void) | undefined;
|
||||
let storedSources: ManagedSource[] | undefined;
|
||||
const vault: PluginHostActionVault = {
|
||||
hosts: [synthetic], customGroups: [], groupConfigs: [], snippets: [], managedSources: [], isInitialized: true,
|
||||
async readPersistedHosts() { beforeRead?.(); return [structuredClone(synthetic)]; },
|
||||
async commitVaultImportTransaction(next, groups, sources, _configs, _expected, _lock, authorize) {
|
||||
const nextSources = sources(storedSources ?? vault.managedSources);
|
||||
beforeCommit?.();
|
||||
await authorize?.();
|
||||
writes++; vault.hosts = next;
|
||||
return { status: 'persisted', groups: groups([]), sources: nextSources, groupConfigs: [] };
|
||||
},
|
||||
};
|
||||
resources.observe(vault.hosts);
|
||||
const target = resources.metadata(synthetic.id);
|
||||
const options = {
|
||||
getVault: () => vault, resources,
|
||||
assertCurrent() { if (!active) throw new Error('Action cancelled'); },
|
||||
async authorizeCommit() { duringAuthorize?.(); if (!active) throw new Error('Action revoked'); authorized++; },
|
||||
connect(host: Host) { assert.equal(host, synthetic); connections++; return 'private-session-id'; },
|
||||
};
|
||||
return { vault, target, options, get authorized() { return authorized; }, get connections() { return connections; }, get writes() { return writes; },
|
||||
cancel() { active = false; }, beforeRead(fn: () => void) { beforeRead = fn; }, beforeCommit(fn: () => void) { beforeCommit = fn; }, duringAuthorize(fn: () => void) { duringAuthorize = fn; },
|
||||
storedSources(value: ManagedSource[]) { storedSources = value; },
|
||||
};
|
||||
}
|
||||
|
||||
test('reviewed connection dispatches through existing handler and returns no session data', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await executePluginHostAction({ kind: 'connect', target: f.target }, f.options), 'requested');
|
||||
assert.equal(f.authorized, 1); assert.equal(f.connections, 1); assert.equal(f.writes, 0);
|
||||
});
|
||||
|
||||
test('host edit preserves credentials and scripts and claims action authority after preparation', async () => {
|
||||
const f = fixture();
|
||||
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Reviewed' } }, f.options), 'saved');
|
||||
assert.equal(f.vault.hosts[0].password, synthetic.password);
|
||||
assert.equal(f.vault.hosts[0].startupCommand, synthetic.startupCommand);
|
||||
assert.equal(f.vault.hosts[0].label, 'Reviewed');
|
||||
assert.equal(f.authorized, 1); assert.equal(f.writes, 1); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
for (const point of ['beforeRead', 'beforeCommit', 'duringAuthorize'] as const) test(`cancel/revoke at ${point} prevents the Vault write`, async () => {
|
||||
const f = fixture(); f[point](() => f.cancel());
|
||||
await assert.rejects(executePluginHostAction({ kind: 'edit', target: f.target, draft: { label: 'Never saved' } }, f.options), /cancelled|revoked/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
for (const change of ['remove', 'replace', 'scripts', 'locked'] as const) test(`${change} during awaited connection preparation prevents dispatch`, async () => {
|
||||
const f = fixture();
|
||||
f.beforeRead(() => {
|
||||
if (change === 'remove') f.vault.hosts = [];
|
||||
if (change === 'replace') f.vault.hosts = [{ ...synthetic }];
|
||||
if (change === 'scripts') f.vault.snippets = [];
|
||||
if (change === 'locked') f.cancel();
|
||||
});
|
||||
await assert.rejects(executePluginHostAction({ kind: 'connect', target: f.target }, f.options));
|
||||
assert.equal(f.authorized, 0); assert.equal(f.connections, 0);
|
||||
});
|
||||
|
||||
test('create produces a saved SSH draft without executable or credential injection', async () => {
|
||||
const f = fixture();
|
||||
const action: PluginHostAction = { kind: 'create', draft: { hostname: 'new.invalid', label: 'New', group: 'Synthetic', password: 'untrusted', startupCommand: 'untrusted' } as never };
|
||||
assert.equal(await executePluginHostAction(action, f.options), 'saved');
|
||||
const created = f.vault.hosts[1];
|
||||
assert.notEqual(created.id, synthetic.id);
|
||||
assert.equal(created.password, undefined); assert.equal(created.startupCommand, undefined);
|
||||
});
|
||||
|
||||
const managedSources: ManagedSource[] = [
|
||||
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
|
||||
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
|
||||
];
|
||||
|
||||
for (const destination of ['B', 'Unmanaged', ''] as const) test(`managed A to ${destination || 'no group'} is refused before action authority or persistence`, async () => {
|
||||
const f = fixture();
|
||||
const host = { ...synthetic, group: 'A', managedSourceId: managedSources[0].id };
|
||||
f.vault.hosts = [host]; f.vault.managedSources = managedSources;
|
||||
f.vault.readPersistedHosts = async () => [structuredClone(host)];
|
||||
f.options.resources.observe(f.vault.hosts);
|
||||
const target = f.options.resources.metadata(host.id);
|
||||
await assert.rejects(executePluginHostAction({ kind: 'edit', target, draft: { group: destination } }, f.options), /Managed.*Vault/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
assert.equal(f.vault.hosts[0].managedSourceId, managedSources[0].id);
|
||||
});
|
||||
|
||||
for (const kind of ['create', 'edit'] as const) test(`${kind} into a managed destination is refused`, async () => {
|
||||
const f = fixture(); f.vault.managedSources = managedSources;
|
||||
const action: PluginHostAction = kind === 'create'
|
||||
? { kind, draft: { hostname: 'new.invalid', group: 'B/Child' } }
|
||||
: { kind, target: f.target, draft: { group: 'B/Child' } };
|
||||
await assert.rejects(executePluginHostAction(action, f.options), /Managed.*Vault/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
|
||||
test('ordinary unmanaged moves preserve the managed-source catalog without taking ownership', async () => {
|
||||
const f = fixture(); f.vault.managedSources = managedSources;
|
||||
assert.equal(await executePluginHostAction({ kind: 'edit', target: f.target, draft: { group: 'Unmanaged' } }, f.options), 'saved');
|
||||
assert.equal(f.vault.hosts[0].managedSourceId, undefined);
|
||||
assert.equal(f.vault.hosts[0].group, 'Unmanaged');
|
||||
assert.equal(f.vault.managedSources, managedSources);
|
||||
});
|
||||
|
||||
test('a managed-source catalog replacement during preparation prevents the write', async () => {
|
||||
const f = fixture();
|
||||
f.beforeCommit(() => { f.vault.managedSources = managedSources; });
|
||||
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
|
||||
test('a newer persisted managed-source catalog rejects a stale unmanaged preview before the claim', async () => {
|
||||
const f = fixture(); f.storedSources(managedSources);
|
||||
await assert.rejects(executePluginHostAction({ kind: 'create', draft: { hostname: 'new.invalid', group: 'B' } }, f.options), /Managed sources changed/);
|
||||
assert.equal(f.writes, 0); assert.equal(f.authorized, 0);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Host, GroupConfig, Snippet, ManagedSource } from '../../domain/models';
|
||||
import { canonicalJsonString, normalizeJsonValue } from '../../domain/convergentSync/json';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import { applyVaultHostCreates } from '../../domain/vaultHostCreate';
|
||||
import type { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { AppVaultRuntime } from './appRuntimeBridge';
|
||||
|
||||
// Application action model. Public RPC types live only in plugin-contract.
|
||||
export type PluginHostAction =
|
||||
| { kind: 'create'; draft: PluginHostDraftFields }
|
||||
| { kind: 'edit'; target: { resourceId: string; revision: string }; draft: PluginHostDraftFields }
|
||||
| { kind: 'connect'; target: { resourceId: string; revision: string } };
|
||||
|
||||
export interface PluginHostActionVault {
|
||||
hosts: Host[];
|
||||
customGroups: string[];
|
||||
groupConfigs: GroupConfig[];
|
||||
snippets: Snippet[];
|
||||
managedSources: ManagedSource[];
|
||||
isInitialized: boolean;
|
||||
readPersistedHosts: () => Promise<Host[]>;
|
||||
commitVaultImportTransaction: AppVaultRuntime['commitVaultImportTransaction'];
|
||||
}
|
||||
|
||||
export interface PluginHostActionExecution {
|
||||
getVault: () => PluginHostActionVault;
|
||||
resources: PluginHostActionResources;
|
||||
assertCurrent: () => void;
|
||||
authorizeCommit: () => Promise<void>;
|
||||
connect: (host: Host) => unknown;
|
||||
}
|
||||
|
||||
const sameHost = (left: Host, right: Host) => (
|
||||
canonicalJsonString(normalizeJsonValue(left)) === canonicalJsonString(normalizeJsonValue(right))
|
||||
);
|
||||
|
||||
/** Execute only a host-reviewed action. authorizeCommit claims separate,
|
||||
* one-use main-process action authority after the final asynchronous work. */
|
||||
export async function executePluginHostAction(action: PluginHostAction, options: PluginHostActionExecution): Promise<'saved' | 'requested'> {
|
||||
const current = () => {
|
||||
options.assertCurrent();
|
||||
const vault = options.getVault();
|
||||
if (!vault.isInitialized) throw new Error('Vault is unavailable');
|
||||
options.resources.observe(vault.hosts);
|
||||
return vault;
|
||||
};
|
||||
const initialVault = current();
|
||||
const reviewedGroups = initialVault.groupConfigs;
|
||||
const reviewedSnippets = initialVault.snippets;
|
||||
const reviewedSources = initialVault.managedSources;
|
||||
const selected = action.kind === 'create' ? undefined : options.resources.resolve(action.target.resourceId, action.target.revision);
|
||||
const assertSelected = () => {
|
||||
const vault = current();
|
||||
if (action.kind !== 'create' && options.resources.resolve(action.target.resourceId, action.target.revision) !== selected) {
|
||||
throw new Error('Selected host changed');
|
||||
}
|
||||
// A connection preview includes inherited and global automation. Any
|
||||
// settings change while the preview is open requires fresh confirmation.
|
||||
if (action.kind === 'connect' && (vault.groupConfigs !== reviewedGroups || vault.snippets !== reviewedSnippets)) {
|
||||
throw new Error('Saved connection settings changed');
|
||||
}
|
||||
if (action.kind !== 'connect' && vault.managedSources !== reviewedSources) throw new Error('Managed sources changed during draft review');
|
||||
return vault;
|
||||
};
|
||||
const persisted = await initialVault.readPersistedHosts();
|
||||
assertSelected();
|
||||
if (selected) {
|
||||
const persistedHost = persisted.find((host) => host.id === selected.id);
|
||||
if (!persistedHost || !sameHost(selected, persistedHost)) throw new Error('Selected host changed');
|
||||
}
|
||||
if (action.kind === 'connect') {
|
||||
await options.authorizeCommit();
|
||||
assertSelected();
|
||||
// The existing host connection handler owns authentication, saved scripts,
|
||||
// and sessions. Its return value never reaches the sandboxed plugin.
|
||||
options.connect(selected!);
|
||||
return 'requested';
|
||||
}
|
||||
const nextHost = action.kind === 'create'
|
||||
? normalizePluginHostDraft(action.draft, crypto.randomUUID())
|
||||
: applyPluginHostEdit(selected!, action.draft);
|
||||
if (pluginHostDraftTouchesManagedSource(nextHost, reviewedSources)) {
|
||||
throw new Error('Managed SSH config changes require the Vault host editor');
|
||||
}
|
||||
const nextHosts = action.kind === 'create'
|
||||
? applyVaultHostCreates(persisted, [], [nextHost]).hosts
|
||||
: persisted.map((host) => host.id === nextHost.id ? nextHost : host);
|
||||
if (action.kind === 'create' && nextHosts.length !== persisted.length + 1) throw new Error('Host already exists');
|
||||
const updateGroups = (groups: string[]) => nextHost.group && !groups.includes(nextHost.group) ? [...groups, nextHost.group] : groups;
|
||||
const sourceSnapshot = canonicalJsonString(normalizeJsonValue(reviewedSources));
|
||||
const keepSources = (sources: ManagedSource[]) => {
|
||||
if (canonicalJsonString(normalizeJsonValue(sources)) !== sourceSnapshot) throw new Error('Managed sources changed during draft review');
|
||||
return sources;
|
||||
};
|
||||
const result = await initialVault.commitVaultImportTransaction(
|
||||
nextHosts, updateGroups, keepSources,
|
||||
undefined, persisted, undefined,
|
||||
async () => { assertSelected(); await options.authorizeCommit(); assertSelected(); },
|
||||
);
|
||||
if (result.status !== 'persisted') throw new Error('Vault changed before the draft was saved');
|
||||
return 'saved';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
const first: Host = { id: 'synthetic-1', label: 'First', hostname: 'first.invalid', username: 'test', tags: [], os: 'linux', group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script', totp: { uri: 'synthetic-totp' } as never };
|
||||
|
||||
test('selected metadata projects only the allowlist and replacement invalidates revision', () => {
|
||||
const resources = new PluginHostActionResources();
|
||||
resources.observe([first]);
|
||||
const metadata = resources.metadata(first.id);
|
||||
assert.deepEqual(Object.keys(metadata), ['resourceId', 'revision', 'label', 'hostname', 'username', 'protocol', 'group', 'tags']);
|
||||
assert.equal(JSON.stringify(metadata).includes('synthetic-password'), false);
|
||||
resources.observe([first]);
|
||||
assert.equal(resources.resolve(first.id, metadata.revision), first);
|
||||
resources.observe([{ ...first }]);
|
||||
assert.throws(() => resources.resolve(first.id, metadata.revision), /changed/);
|
||||
const replaced = resources.metadata(first.id);
|
||||
resources.observe([]);
|
||||
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
|
||||
resources.observe([first]);
|
||||
assert.throws(() => resources.resolve(first.id, replaced.revision), /changed/);
|
||||
});
|
||||
|
||||
test('group choices are explicit current members and never expand to later members', () => {
|
||||
const resources = new PluginHostActionResources();
|
||||
resources.observe([first]);
|
||||
const selection = [resources.metadata(first.id)];
|
||||
resources.observe([first, { ...first, id: 'synthetic-2' }]);
|
||||
assert.deepEqual(selection.map((item) => item.resourceId), [first.id]);
|
||||
assert.equal(resources.resolve(first.id, selection[0].revision), first);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Host } from '../../domain/models';
|
||||
|
||||
/** Renderer-local revisions track replacement as well as visible edits. They
|
||||
* are never authorization handles; only the main-process authority issues one. */
|
||||
export class PluginHostActionResources {
|
||||
private entries = new Map<string, { host: Host; revision: string }>();
|
||||
|
||||
observe(hosts: readonly Host[]) {
|
||||
const next = new Map<string, { host: Host; revision: string }>();
|
||||
for (const host of hosts) {
|
||||
const existing = this.entries.get(host.id);
|
||||
next.set(host.id, existing?.host === host ? existing : { host, revision: crypto.randomUUID() });
|
||||
}
|
||||
this.entries = next;
|
||||
}
|
||||
|
||||
resolve(id: string, expectedRevision: string): Host {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry || entry.revision !== expectedRevision) throw new Error('Selected host changed; select it again');
|
||||
return entry.host;
|
||||
}
|
||||
|
||||
metadata(id: string) {
|
||||
const entry = this.entries.get(id);
|
||||
if (!entry) throw new Error('Selected host is unavailable');
|
||||
const host = entry.host;
|
||||
return {
|
||||
resourceId: host.id, revision: entry.revision,
|
||||
label: host.label, hostname: host.hostname, username: host.username,
|
||||
...(host.port === undefined ? {} : { port: host.port }), protocol: host.protocol ?? 'ssh',
|
||||
group: host.group ?? '', tags: [...host.tags],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export const PLUGIN_VAULT_PAGE_PREFIX = 'plugin-vault:';
|
||||
|
||||
export interface PluginVaultPage {
|
||||
key: `plugin-vault:${string}`;
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
version: string;
|
||||
viewId: string;
|
||||
title: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export function collectPluginVaultPages(plugins: NetcattyPluginContributionSnapshot['plugins']): PluginVaultPage[] {
|
||||
return plugins.flatMap((plugin) => plugin.views
|
||||
.filter((view) => view.location === 'vault-page' && view.visible)
|
||||
.map((view): PluginVaultPage => ({
|
||||
key: `${PLUGIN_VAULT_PAGE_PREFIX}${view.id}`,
|
||||
pluginId: plugin.id, pluginName: plugin.displayName, version: plugin.version,
|
||||
viewId: view.id, title: view.title, icon: view.icon, order: view.order ?? 0,
|
||||
})))
|
||||
.sort((left, right) => left.order - right.order || left.viewId.localeCompare(right.viewId));
|
||||
}
|
||||
|
||||
export function resolvePluginVaultPage(pages: readonly PluginVaultPage[], section: string): PluginVaultPage | null {
|
||||
return pages.find((page) => page.key === section) ?? null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** Native child views must yield to host dialogs and overlapping portals. */
|
||||
export function isPluginViewOccluded(mount: HTMLElement): boolean {
|
||||
const rect = mount.getBoundingClientRect();
|
||||
if (!mount.isConnected || mount.closest('[hidden], [inert], [aria-hidden="true"]')) return true;
|
||||
const overlays = mount.ownerDocument.querySelectorAll(
|
||||
'[data-app-lock-overlay], [role="dialog"], [role="alertdialog"], '
|
||||
+ '[role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *, [data-native-view-occluder]',
|
||||
);
|
||||
return [...overlays].some((overlay) => {
|
||||
if (mount.contains(overlay)) return false;
|
||||
const style = getComputedStyle(overlay);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || overlay.closest('[hidden], [inert]')) return false;
|
||||
if (overlay.matches('[data-app-lock-overlay], [role="dialog"][aria-modal="true"], [role="alertdialog"]')) return true;
|
||||
const other = overlay.getBoundingClientRect();
|
||||
return other.width > 0 && other.height > 0 && other.x < rect.right && other.right > rect.x
|
||||
&& other.y < rect.bottom && other.bottom > rect.y;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -9,6 +9,106 @@ test("terminalHiddenRendererStore evicts oldest soft-hidden session", () => {
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.pickEvictionCandidate(2), "a");
|
||||
assert.deepEqual(terminalHiddenRendererStore.listSoftHiddenOldestFirst(), ["a", "b"]);
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
terminalHiddenRendererStore.clearSoftHidden("b");
|
||||
});
|
||||
|
||||
test("evictSoftHidden reports refusal and drops an unconsumed request", async () => {
|
||||
const sessions = ["images", "plain", "other"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("images");
|
||||
terminalHiddenRendererStore.markSoftHidden("plain");
|
||||
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) return;
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("plain")) {
|
||||
terminalHiddenRendererStore.clearSoftHidden("plain");
|
||||
terminalHiddenRendererStore.settleEviction("plain", true);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("plain"), true);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("plain"), false);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
|
||||
terminalHiddenRendererStore.markSoftHidden("images");
|
||||
assert.equal(await terminalHiddenRendererStore.evictSoftHidden("images"), false);
|
||||
const delivered: string[] = [];
|
||||
const later = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (terminalHiddenRendererStore.consumeEvictionRequest("images")) delivered.push("images");
|
||||
});
|
||||
try {
|
||||
terminalHiddenRendererStore.markSoftHidden("other");
|
||||
assert.deepEqual(delivered, []);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("images"), true);
|
||||
} finally {
|
||||
later();
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
test("a reserved leaving slot counts toward occupancy until it is released", () => {
|
||||
const sessions = ["a", "b", "c", "d"];
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
terminalHiddenRendererStore.markSoftHidden("a");
|
||||
terminalHiddenRendererStore.markSoftHidden("b");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
|
||||
const alreadyHidden = terminalHiddenRendererStore.reserveLeavingSlot("a");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("a", alreadyHidden);
|
||||
|
||||
const tokenC = terminalHiddenRendererStore.reserveLeavingSlot("c");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
|
||||
|
||||
terminalHiddenRendererStore.clearSoftHidden("a");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), true);
|
||||
|
||||
const tokenD = terminalHiddenRendererStore.reserveLeavingSlot("d");
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("c", 2), false);
|
||||
assert.equal(terminalHiddenRendererStore.leavingSlotStillFits("d", 2), false);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenD);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 3);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("c", tokenC);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 2);
|
||||
terminalHiddenRendererStore.releaseLeavingSlot("d", tokenD);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 1);
|
||||
|
||||
for (const sessionId of sessions) terminalHiddenRendererStore.clearSoftHidden(sessionId);
|
||||
assert.equal(terminalHiddenRendererStore.getSoftHiddenOccupancy(), 0);
|
||||
});
|
||||
|
||||
test("evictSoftHidden waits for hibernate after the marker is cleared", async () => {
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
let markerClearedBeforeSettle = false;
|
||||
const unsubscribe = terminalHiddenRendererStore.subscribe(() => {
|
||||
if (!terminalHiddenRendererStore.consumeEvictionRequest("victim")) return;
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
markerClearedBeforeSettle = !terminalHiddenRendererStore.isSoftHidden("victim");
|
||||
queueMicrotask(() => {
|
||||
terminalHiddenRendererStore.markSoftHidden("victim");
|
||||
terminalHiddenRendererStore.settleEviction("victim", false);
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const completed = await terminalHiddenRendererStore.evictSoftHidden("victim");
|
||||
assert.equal(markerClearedBeforeSettle, true);
|
||||
assert.equal(completed, false);
|
||||
assert.equal(terminalHiddenRendererStore.isSoftHidden("victim"), true);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
terminalHiddenRendererStore.clearSoftHidden("victim");
|
||||
}
|
||||
});
|
||||
@@ -3,14 +3,85 @@ type Listener = () => void;
|
||||
const softHiddenSessions = new Map<string, number>();
|
||||
const listeners = new Set<Listener>();
|
||||
let evictionRequestSessionId: string | null = null;
|
||||
/** Leaving tabs that have claimed a keep slot while a victim hibernate is in flight. */
|
||||
const reservedLeavingSlots = new Map<string, number>();
|
||||
let reservationToken = 0;
|
||||
|
||||
function reservedLeavingCount(): number {
|
||||
let extra = 0;
|
||||
for (const sessionId of reservedLeavingSlots.keys()) {
|
||||
if (!softHiddenSessions.has(sessionId)) extra += 1;
|
||||
}
|
||||
return extra;
|
||||
}
|
||||
|
||||
type EvictionWaiter = {
|
||||
promise: Promise<boolean>;
|
||||
resolve: (completed: boolean) => void;
|
||||
settled: boolean;
|
||||
};
|
||||
|
||||
const evictionWaiters = new Map<string, EvictionWaiter>();
|
||||
|
||||
function finishEviction(sessionId: string, completed: boolean): void {
|
||||
const waiter = evictionWaiters.get(sessionId);
|
||||
if (!waiter || waiter.settled) return;
|
||||
waiter.settled = true;
|
||||
evictionWaiters.delete(sessionId);
|
||||
if (evictionRequestSessionId === sessionId) evictionRequestSessionId = null;
|
||||
waiter.resolve(completed);
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function listSoftHiddenOldestFirst(): string[] {
|
||||
return [...softHiddenSessions.entries()]
|
||||
.sort((left, right) => left[1] - right[1])
|
||||
.map(([sessionId]) => sessionId);
|
||||
}
|
||||
|
||||
export const terminalHiddenRendererStore = {
|
||||
getSoftHiddenCount: () => softHiddenSessions.size,
|
||||
|
||||
/**
|
||||
* Soft-hidden markers plus leaving tabs that reserved a slot and are not
|
||||
* yet in the marker set. Keep-count decisions use this so a second timer
|
||||
* cannot take the slot a pending eviction already claimed.
|
||||
*/
|
||||
getSoftHiddenOccupancy: () => softHiddenSessions.size + reservedLeavingCount(),
|
||||
|
||||
/**
|
||||
* Claim one keep slot for a leaving tab before awaiting eviction.
|
||||
* A session already in the marker set is not counted twice. A later claim
|
||||
* for the same id replaces the token; only that token can release it.
|
||||
*/
|
||||
reserveLeavingSlot: (sessionId: string): number => {
|
||||
const token = ++reservationToken;
|
||||
if (sessionId && !softHiddenSessions.has(sessionId)) {
|
||||
reservedLeavingSlots.set(sessionId, token);
|
||||
}
|
||||
return token;
|
||||
},
|
||||
|
||||
releaseLeavingSlot: (sessionId: string, token: number) => {
|
||||
if (reservedLeavingSlots.get(sessionId) === token) {
|
||||
reservedLeavingSlots.delete(sessionId);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* True when retaining this leaving tab would stay within keepCount.
|
||||
* Its own reservation is not counted against the slots held by others.
|
||||
*/
|
||||
leavingSlotStillFits: (sessionId: string, keepCount: number): boolean => {
|
||||
if (!(keepCount > 0)) return false;
|
||||
const reservedHere = reservedLeavingSlots.has(sessionId) && !softHiddenSessions.has(sessionId);
|
||||
const others = softHiddenSessions.size + reservedLeavingCount() - (reservedHere ? 1 : 0);
|
||||
return others < keepCount;
|
||||
},
|
||||
|
||||
isSoftHidden: (sessionId: string) => softHiddenSessions.has(sessionId),
|
||||
|
||||
markSoftHidden: (sessionId: string) => {
|
||||
@@ -23,18 +94,12 @@ export const terminalHiddenRendererStore = {
|
||||
emit();
|
||||
},
|
||||
|
||||
listSoftHiddenOldestFirst: (): string[] => listSoftHiddenOldestFirst(),
|
||||
|
||||
/** Returns the oldest soft-hidden session id when over the keep limit. */
|
||||
pickEvictionCandidate: (keepCount: number): string | null => {
|
||||
if (softHiddenSessions.size < keepCount) return null;
|
||||
let oldestId: string | null = null;
|
||||
let oldestTs = Number.POSITIVE_INFINITY;
|
||||
for (const [sessionId, ts] of softHiddenSessions) {
|
||||
if (ts < oldestTs) {
|
||||
oldestTs = ts;
|
||||
oldestId = sessionId;
|
||||
}
|
||||
}
|
||||
return oldestId;
|
||||
return listSoftHiddenOldestFirst()[0] ?? null;
|
||||
},
|
||||
|
||||
requestEviction: (sessionId: string) => {
|
||||
@@ -42,6 +107,44 @@ export const terminalHiddenRendererStore = {
|
||||
emit();
|
||||
},
|
||||
|
||||
/** True while this session's hibernate has been requested and not settled. */
|
||||
hasPendingEviction: (sessionId: string): boolean => {
|
||||
const waiter = evictionWaiters.get(sessionId);
|
||||
return waiter != null && !waiter.settled;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ask one soft-hidden session to upgrade. Resolves true only when
|
||||
* settleEviction reports that hibernation finished. Clearing the marker
|
||||
* during wake is not success: drain, snapshot, or alternate-screen failure
|
||||
* can still restore that renderer. A synchronous refusal (marker still
|
||||
* present, including inline images) resolves false and drops an unconsumed
|
||||
* request so a later emit cannot upgrade it.
|
||||
*/
|
||||
evictSoftHidden: (sessionId: string): Promise<boolean> => {
|
||||
const existing = evictionWaiters.get(sessionId);
|
||||
if (existing) return existing.promise;
|
||||
if (!softHiddenSessions.has(sessionId)) return Promise.resolve(false);
|
||||
|
||||
let resolvePromise!: (completed: boolean) => void;
|
||||
const promise = new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
const waiter: EvictionWaiter = { promise, resolve: resolvePromise, settled: false };
|
||||
evictionWaiters.set(sessionId, waiter);
|
||||
evictionRequestSessionId = sessionId;
|
||||
emit();
|
||||
if (!waiter.settled && softHiddenSessions.has(sessionId)) {
|
||||
finishEviction(sessionId, false);
|
||||
}
|
||||
return promise;
|
||||
},
|
||||
|
||||
/** Record whether the in-flight eviction's hibernate actually completed. */
|
||||
settleEviction: (sessionId: string, completed: boolean) => {
|
||||
finishEviction(sessionId, completed);
|
||||
},
|
||||
|
||||
consumeEvictionRequest: (sessionId: string): boolean => {
|
||||
if (evictionRequestSessionId !== sessionId) return false;
|
||||
evictionRequestSessionId = null;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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,168 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, VaultHostActionRequest } from '@netcatty/plugin-contract';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { getAppHandlers } from '../app/appHandlersBridge';
|
||||
import { getAppAppLockRuntime, getAppVaultRuntime } from './appRuntimeBridge';
|
||||
import { PluginHostActionResources } from './pluginHostActionResources';
|
||||
import { executePluginHostAction, type PluginHostAction } from './pluginHostActionExecution';
|
||||
import { pluginHostRunsSavedScripts, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import type { Host, GroupConfig, ManagedSource, Snippet } from '../../domain/models';
|
||||
|
||||
export type VaultHostPrompt = Omit<HostInteractionPrompt, 'request'> & { request: VaultHostActionRequest };
|
||||
type Review = {
|
||||
prompt: VaultHostPrompt;
|
||||
candidates: Host[];
|
||||
candidateRevisions: Map<string, string>;
|
||||
customGroups: string[];
|
||||
host?: Host;
|
||||
groups: GroupConfig[];
|
||||
snippets: Snippet[];
|
||||
managedSources: ManagedSource[];
|
||||
scriptsRun: boolean;
|
||||
claimed: boolean;
|
||||
completionToken?: string;
|
||||
};
|
||||
|
||||
export function usePluginHostActions() {
|
||||
const [review, setReview] = useState<Review | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const current = useRef<Review | null>(null);
|
||||
const resources = useRef(new PluginHostActionResources());
|
||||
const responding = useRef(false);
|
||||
|
||||
const getVault = useCallback(() => {
|
||||
const vault = getAppVaultRuntime();
|
||||
const lock = getAppAppLockRuntime();
|
||||
if (!vault?.isInitialized || !lock?.initialized || lock.locked) throw new Error('Vault is locked or unavailable');
|
||||
const actionState = vault.readCurrentHostActionState();
|
||||
resources.current.observe(actionState.hosts);
|
||||
return { ...vault, ...actionState };
|
||||
}, []);
|
||||
|
||||
const assertCurrent = useCallback((entry: Review) => {
|
||||
if (current.current !== entry || Date.now() >= entry.prompt.expiresAt) throw new Error('Host action expired');
|
||||
const vault = getVault();
|
||||
const target = entry.prompt.vaultTarget;
|
||||
if (target && resources.current.resolve(target.resourceId, target.revision) !== entry.host) throw new Error('Selected host changed');
|
||||
if (entry.prompt.request.kind === 'vault.requestConnect' && (vault.groupConfigs !== entry.groups || vault.snippets !== entry.snippets)) {
|
||||
throw new Error('Saved connection settings changed');
|
||||
}
|
||||
if (entry.prompt.request.kind === 'vault.requestHostDraft' && vault.managedSources !== entry.managedSources) throw new Error('Managed sources changed during draft review');
|
||||
}, [getVault]);
|
||||
|
||||
const fail = useCallback(async (entry: Review) => {
|
||||
if (current.current !== entry) return;
|
||||
setError(true);
|
||||
try {
|
||||
await netcattyBridge.get()?.respondPluginInteraction?.({
|
||||
requestId: entry.prompt.requestId, actionToken: entry.completionToken ?? entry.prompt.actionToken, action: 'fail',
|
||||
});
|
||||
} catch { /* A stopped runtime or closed window already invalidates it. */ }
|
||||
}, []);
|
||||
|
||||
const execute = useCallback(async (entry: Review) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.respondPluginInteraction) return fail(entry);
|
||||
setBusy(true);
|
||||
try {
|
||||
assertCurrent(entry);
|
||||
const request = entry.prompt.request;
|
||||
const target = entry.prompt.vaultTarget;
|
||||
let action: PluginHostAction;
|
||||
if (request.kind === 'vault.requestConnect' && target) action = { kind: 'connect', target };
|
||||
else if (request.kind === 'vault.requestHostDraft' && entry.prompt.reviewedDraft) {
|
||||
action = target ? { kind: 'edit', target, draft: entry.prompt.reviewedDraft } : { kind: 'create', draft: entry.prompt.reviewedDraft };
|
||||
} else throw new Error('Host action is invalid');
|
||||
await executePluginHostAction(action, {
|
||||
getVault, resources: resources.current,
|
||||
assertCurrent: () => assertCurrent(entry),
|
||||
authorizeCommit: async () => {
|
||||
assertCurrent(entry);
|
||||
const receipt = await bridge.respondPluginInteraction!({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'commit' });
|
||||
if (!receipt || typeof receipt !== 'object' || !receipt.completionToken) throw new Error('Host action was revoked');
|
||||
entry.completionToken = receipt.completionToken;
|
||||
entry.claimed = true;
|
||||
assertCurrent(entry);
|
||||
},
|
||||
connect: (host) => {
|
||||
const connect = getAppHandlers()?.handleConnectToHost;
|
||||
if (typeof connect !== 'function') throw new Error('Host connection flow unavailable');
|
||||
return connect(host);
|
||||
},
|
||||
});
|
||||
await bridge.respondPluginInteraction({ requestId: entry.prompt.requestId, actionToken: entry.completionToken!, action: 'complete' });
|
||||
} catch { await fail(entry); }
|
||||
}, [assertCurrent, fail, getVault]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onPluginInteraction) return;
|
||||
const unsubscribe = bridge.onPluginInteraction((event) => {
|
||||
if ('closed' in event) {
|
||||
if (current.current?.prompt.requestId !== event.requestId) return;
|
||||
current.current = null; setReview(null); setBusy(false); responding.current = false;
|
||||
return;
|
||||
}
|
||||
if (!event.request.kind.startsWith('vault.')) return;
|
||||
const prompt = event as VaultHostPrompt;
|
||||
try {
|
||||
if (prompt.phase === 'commit') {
|
||||
const entry = current.current;
|
||||
if (!entry || entry.prompt.requestId !== prompt.requestId || entry.prompt.request.kind !== prompt.request.kind) throw new Error('Host review is unavailable');
|
||||
entry.prompt = prompt;
|
||||
setReview({ ...entry });
|
||||
void execute(entry);
|
||||
return;
|
||||
}
|
||||
const vault = getVault();
|
||||
const candidates = [...vault.hosts];
|
||||
const host = prompt.vaultTarget ? resources.current.resolve(prompt.vaultTarget.resourceId, prompt.vaultTarget.revision) : undefined;
|
||||
const entry: Review = {
|
||||
prompt, candidates, candidateRevisions: new Map(candidates.map((candidate) => [candidate.id, resources.current.metadata(candidate.id).revision])),
|
||||
customGroups: vault.customGroups, host, groups: vault.groupConfigs, snippets: vault.snippets,
|
||||
managedSources: vault.managedSources,
|
||||
scriptsRun: host ? pluginHostRunsSavedScripts(host, vault.groupConfigs, vault.snippets) : false,
|
||||
claimed: false,
|
||||
};
|
||||
current.current = entry; setReview(entry); setSelectedIds([]); setBusy(false); setError(false); responding.current = false;
|
||||
} catch {
|
||||
void bridge.respondPluginInteraction?.({ requestId: prompt.requestId, actionToken: prompt.actionToken, action: 'fail' }).catch(() => {});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
const entry = current.current;
|
||||
current.current = null;
|
||||
unsubscribe();
|
||||
if (entry && !entry.claimed) void bridge.respondPluginInteraction?.({ requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action: 'cancel' }).catch(() => {});
|
||||
};
|
||||
}, [execute, getVault]);
|
||||
|
||||
const respond = useCallback(async (action: 'cancel' | 'submit', draft?: PluginHostDraftFields) => {
|
||||
const entry = current.current;
|
||||
if (!entry || responding.current || (action === 'cancel' && entry.claimed)) return;
|
||||
responding.current = true; setBusy(true); setError(false);
|
||||
try {
|
||||
assertCurrent(entry);
|
||||
const request = entry.prompt.request;
|
||||
const selected = request.kind === 'vault.selectHosts' && action === 'submit' ? selectedIds.map((id) => {
|
||||
const revision = entry.candidateRevisions.get(id);
|
||||
if (!revision) throw new Error('Host was not part of the current selection');
|
||||
resources.current.resolve(id, revision);
|
||||
return resources.current.metadata(id);
|
||||
}) : undefined;
|
||||
await netcattyBridge.require().respondPluginInteraction?.({
|
||||
requestId: entry.prompt.requestId, actionToken: entry.prompt.actionToken, action,
|
||||
...(selected === undefined ? {} : { resources: selected }),
|
||||
...(draft === undefined ? {} : { draft }),
|
||||
...(request.kind === 'vault.requestConnect' && action === 'submit' ? { scriptsRun: entry.scriptsRun } : {}),
|
||||
});
|
||||
} catch { await fail(entry); }
|
||||
finally {
|
||||
if (current.current === entry && entry.prompt.phase === 'request') { responding.current = false; setBusy(false); }
|
||||
}
|
||||
}, [assertCurrent, fail, selectedIds]);
|
||||
|
||||
return { review, selectedIds, setSelectedIds, busy, error, respond };
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HostInteractionPrompt, HostInteractionRequest, HostInteractionResponse } from '@netcatty/plugin-contract';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function usePluginHostInteractions() {
|
||||
type UiPrompt = Omit<HostInteractionPrompt, 'request'> & { request: HostInteractionRequest };
|
||||
const [prompt, setPrompt] = useState<UiPrompt | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [error, setError] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const current = useRef<UiPrompt | 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 {
|
||||
if (event.request.kind.startsWith('vault.')) return;
|
||||
current.current = event as UiPrompt; setPrompt(event as UiPrompt);
|
||||
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(); }); }
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { activeTabStore, useActiveTabId } from './activeTabStore';
|
||||
import { collectPluginVaultPages, PLUGIN_VAULT_PAGE_PREFIX, resolvePluginVaultPage } from './pluginVaultPages';
|
||||
import { usePluginContributions } from './usePluginContributions';
|
||||
import { OPEN_PLUGIN_VIEW_EVENT, type OpenPluginViewDetail } from './usePluginViewLifecycle';
|
||||
|
||||
const CONTEXT = Object.freeze({ 'netcatty.surface': 'vault-page' });
|
||||
|
||||
export function usePluginVaultPages({ locale, section, select }: {
|
||||
locale: string;
|
||||
section: string;
|
||||
select(section: 'hosts' | `plugin-vault:${string}`): void;
|
||||
}) {
|
||||
const active = useActiveTabId() === 'vault';
|
||||
const sectionRef = useRef(section);
|
||||
const focusFrame = useRef<number | null>(null);
|
||||
sectionRef.current = section;
|
||||
useEffect(() => () => { if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current); }, []);
|
||||
const contributions = usePluginContributions({ locale, context: CONTEXT });
|
||||
const pages = useMemo(() => collectPluginVaultPages(contributions.snapshot.plugins), [contributions.snapshot.plugins]);
|
||||
const selected = resolvePluginVaultPage(pages, section);
|
||||
const fallback = useCallback(() => {
|
||||
select('hosts');
|
||||
if (focusFrame.current !== null) cancelAnimationFrame(focusFrame.current);
|
||||
focusFrame.current = requestAnimationFrame(() => {
|
||||
focusFrame.current = null;
|
||||
if (sectionRef.current === 'hosts' && activeTabStore.getActiveTabId() === 'vault') {
|
||||
document.querySelector<HTMLButtonElement>('[data-plugin-vault-fallback]')?.focus();
|
||||
}
|
||||
});
|
||||
}, [select]);
|
||||
useEffect(() => {
|
||||
if (!contributions.loading && section.startsWith(PLUGIN_VAULT_PAGE_PREFIX) && !selected) fallback();
|
||||
}, [contributions.loading, fallback, section, selected]);
|
||||
useEffect(() => {
|
||||
const open = (event: Event) => {
|
||||
const detail = (event as CustomEvent<OpenPluginViewDetail>).detail;
|
||||
const page = pages.find((item) => item.viewId === detail?.viewId);
|
||||
if (!page) return;
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
select(page.key);
|
||||
};
|
||||
window.addEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
|
||||
return () => window.removeEventListener(OPEN_PLUGIN_VIEW_EVENT, open);
|
||||
}, [pages, select]);
|
||||
return { pages, selected, active, select, fallback, contributions, context: CONTEXT };
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function usePluginViewLifecycle({
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const resolvedActiveView = useMemo(() => viewContributions.snapshot.plugins
|
||||
.flatMap((plugin) => plugin.views.map((view) => ({ plugin, view })))
|
||||
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible) ?? null,
|
||||
.find(({ view }) => view.id === effectiveRequested?.viewId && view.visible && !['vault-page', 'terminal-sidebar'].includes(view.location)) ?? null,
|
||||
[effectiveRequested?.viewId, viewContributions.snapshot.plugins]);
|
||||
const stableActiveViewRef = useRef<PluginViewSnapshotSelection<ResolvedPluginView> | null>(null);
|
||||
const activeView = resolvePluginViewSnapshotSelection({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -279,6 +279,82 @@ test('a pending side-panel write survives an urgent render on another tab', asyn
|
||||
}
|
||||
});
|
||||
|
||||
test('a pending tool switch does not leave another tab ref behind an urgent open', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost' });
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: dom.window.document });
|
||||
const actEnvironment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let state: ReturnType<typeof useTerminalSidePanelLayoutState> | null = null;
|
||||
const Probe = () => {
|
||||
state = useTerminalSidePanelLayoutState();
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root: Root = createRoot(container);
|
||||
const focusedTool = (tabId: string, fromRef = false) => {
|
||||
const layout = fromRef
|
||||
? state?.sidePanelLayoutsRef.current.get(tabId)
|
||||
: state?.sidePanelLayouts.get(tabId);
|
||||
if (!layout) return null;
|
||||
return collectSidePanelPanes(layout.root).find((pane) => pane.id === layout.focusedPaneId)?.tool ?? null;
|
||||
};
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Probe));
|
||||
});
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'notes');
|
||||
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
startTransition(() => {
|
||||
state?.selectSidePanelToolForTab('terminal-a', 'history');
|
||||
});
|
||||
flushSync(() => {
|
||||
state?.setSidePanelOpenTabs((previous) => new Map(previous).set('terminal-b', 'browser'));
|
||||
});
|
||||
});
|
||||
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'browser');
|
||||
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-a'), 'history');
|
||||
assert.equal(
|
||||
state!.sidePanelOpenTabsRef.current.get('terminal-b'),
|
||||
'browser',
|
||||
'B stays rendered as browser while its ref still reports the pre-queue tool',
|
||||
);
|
||||
assert.equal(focusedTool('terminal-b', true), 'browser');
|
||||
|
||||
// handleSwitchSidePanelTab returns before select when the ref already
|
||||
// matches the clicked tool. A stale scripts ref would ignore this click.
|
||||
assert.notEqual(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
|
||||
await act(async () => {
|
||||
state?.selectSidePanelToolForTab('terminal-b', 'scripts');
|
||||
});
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-b'), 'scripts');
|
||||
assert.equal(state!.sidePanelOpenTabsRef.current.get('terminal-b'), 'scripts');
|
||||
assert.equal(focusedTool('terminal-b'), 'scripts');
|
||||
assert.equal(state!.sidePanelOpenTabs.get('terminal-a'), 'history');
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('side panel icon clicks write the tool through selectSidePanelToolForTab', () => {
|
||||
const layerSource = readFileSync(new URL('../../components/TerminalLayer.tsx', import.meta.url), 'utf8');
|
||||
const start = layerSource.indexOf('const handleSwitchSidePanelTab = useCallback');
|
||||
|
||||
@@ -27,6 +27,64 @@ function deleteMapEntry<T>(previous: Map<string, T>, key: string): Map<string, T
|
||||
return next;
|
||||
}
|
||||
|
||||
function entryMatches<T>(left: Map<string, T>, right: Map<string, T>, key: string): boolean {
|
||||
return left.has(key) === right.has(key) && left.get(key) === right.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep a ref aligned with committed state without dropping a write React has
|
||||
* not rendered yet. An urgent update can rebase onto a transition: the
|
||||
* transition updater stores an intermediate map in the ref, and the later
|
||||
* updater changes only state. Copy committed entries the pending map did not
|
||||
* change, and follow committed once it moves past that pending value.
|
||||
*/
|
||||
function syncTrackedMap<T>(
|
||||
pending: Map<string, T>,
|
||||
lastRendered: Map<string, T>,
|
||||
committed: Map<string, T>,
|
||||
): Map<string, T> {
|
||||
if (pending === lastRendered) return committed;
|
||||
if (pending === committed) return pending;
|
||||
|
||||
let changed = false;
|
||||
const merged = new Map(pending);
|
||||
|
||||
for (const key of committed.keys()) {
|
||||
if (entryMatches(pending, committed, key)) continue;
|
||||
const pendingMatchesRendered = entryMatches(pending, lastRendered, key);
|
||||
const committedMatchesRendered = entryMatches(committed, lastRendered, key);
|
||||
if (pendingMatchesRendered || !committedMatchesRendered) {
|
||||
merged.set(key, committed.get(key) as T);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of pending.keys()) {
|
||||
if (committed.has(key)) continue;
|
||||
if (entryMatches(pending, lastRendered, key)) {
|
||||
merged.delete(key);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? merged : pending;
|
||||
}
|
||||
|
||||
function publishQueuedMap<T>(
|
||||
live: Map<string, T>,
|
||||
capturedBase: Map<string, T>,
|
||||
previous: Map<string, T>,
|
||||
stateNext: Map<string, T>,
|
||||
tabId: string,
|
||||
value: T | null,
|
||||
): Map<string, T> {
|
||||
// The captured map is the synchronous write. Replaying it after an
|
||||
// intervening render drops entries that render already merged. When the ref
|
||||
// has moved ahead of this updater's base, reapply only this tab.
|
||||
if (live === capturedBase || live === previous) return stateNext;
|
||||
return value === null ? deleteMapEntry(live, tabId) : replaceMapEntry(live, tabId, value);
|
||||
}
|
||||
|
||||
function reconcileSidePanelLayouts(
|
||||
layouts: Map<string, SidePanelLayout>,
|
||||
openTabs: Map<string, SidePanelTool>,
|
||||
@@ -62,17 +120,22 @@ export function useTerminalSidePanelLayoutState() {
|
||||
const sidePanelLayoutsRef = useRef(sidePanelLayouts);
|
||||
// Last maps this hook rendered. A transition can update the refs before React
|
||||
// commits them; an urgent render in between must not copy the stale state
|
||||
// back over that pending write. Functional updates still merge onto queued
|
||||
// state when the ref and the committed map have diverged.
|
||||
// back over that pending write. Once the queue commits, entries the pending
|
||||
// write did not touch still have to catch up, or the ref stays on the
|
||||
// transition updater's intermediate map.
|
||||
const renderedOpenTabsRef = useRef(sidePanelOpenTabs);
|
||||
const renderedLayoutsRef = useRef(sidePanelLayouts);
|
||||
if (sidePanelOpenTabsRef.current === renderedOpenTabsRef.current) {
|
||||
sidePanelOpenTabsRef.current = sidePanelOpenTabs;
|
||||
}
|
||||
sidePanelOpenTabsRef.current = syncTrackedMap(
|
||||
sidePanelOpenTabsRef.current,
|
||||
renderedOpenTabsRef.current,
|
||||
sidePanelOpenTabs,
|
||||
);
|
||||
renderedOpenTabsRef.current = sidePanelOpenTabs;
|
||||
if (sidePanelLayoutsRef.current === renderedLayoutsRef.current) {
|
||||
sidePanelLayoutsRef.current = sidePanelLayouts;
|
||||
}
|
||||
sidePanelLayoutsRef.current = syncTrackedMap(
|
||||
sidePanelLayoutsRef.current,
|
||||
renderedLayoutsRef.current,
|
||||
sidePanelLayouts,
|
||||
);
|
||||
renderedLayoutsRef.current = sidePanelLayouts;
|
||||
|
||||
// Legacy and external open paths write the focused-tool map. Reconcile that
|
||||
@@ -117,14 +180,28 @@ export function useTerminalSidePanelLayoutState() {
|
||||
const next = previous === layoutBase
|
||||
? layouts
|
||||
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, layout));
|
||||
sidePanelLayoutsRef.current = next;
|
||||
sidePanelLayoutsRef.current = publishQueuedMap(
|
||||
sidePanelLayoutsRef.current,
|
||||
layoutBase,
|
||||
previous,
|
||||
next,
|
||||
tabId,
|
||||
openTab === null ? null : layout,
|
||||
);
|
||||
return next;
|
||||
});
|
||||
setSidePanelOpenTabs((previous) => {
|
||||
const next = previous === openTabBase
|
||||
? openTabs
|
||||
: (openTab === null ? deleteMapEntry(previous, tabId) : replaceMapEntry(previous, tabId, openTab));
|
||||
sidePanelOpenTabsRef.current = next;
|
||||
sidePanelOpenTabsRef.current = publishQueuedMap(
|
||||
sidePanelOpenTabsRef.current,
|
||||
openTabBase,
|
||||
previous,
|
||||
next,
|
||||
tabId,
|
||||
openTab,
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -776,6 +776,13 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
}
|
||||
}, [waitForPendingVaultWrites]);
|
||||
|
||||
// Host-owned action guards need the synchronous mutation snapshot, including
|
||||
// edits that React has not published yet. This accessor never crosses RPC.
|
||||
const readCurrentHostActionState = useCallback(() => ({
|
||||
hosts: hostsRef.current, groupConfigs: groupConfigsRef.current, snippets: snippetsRef.current,
|
||||
managedSources: managedSourcesRef.current,
|
||||
}), []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
const cleaned = normalizeVaultOrder(data);
|
||||
setKeys(cleaned);
|
||||
@@ -1248,6 +1255,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
updateGroupConfigs?: (current: GroupConfig[]) => GroupConfig[],
|
||||
expectedHosts?: Host[],
|
||||
lock?: VaultLockHandle | null,
|
||||
assertAuthorized?: () => void | Promise<void>,
|
||||
): Promise<
|
||||
| {
|
||||
status: "persisted";
|
||||
@@ -1322,6 +1330,10 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
encryptHosts(cleanedHosts),
|
||||
encryptGroupConfigs(nextGroupConfigs),
|
||||
]);
|
||||
// Optional plugin draft guard: runtime/source/revoke/lock authority must
|
||||
// still hold after encryption, immediately before the existing atomic
|
||||
// persistence boundary. Ordinary imports retain their existing behavior.
|
||||
if (assertAuthorized) await assertAuthorized();
|
||||
if (
|
||||
version !== hostsWriteVersion.current
|
||||
|| groupConfigsVersion !== groupConfigsWriteVersion.current
|
||||
@@ -2519,6 +2531,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
groupConfigs,
|
||||
updateHosts,
|
||||
readPersistedHosts,
|
||||
readCurrentHostActionState,
|
||||
updateKeys,
|
||||
importOrReuseKey,
|
||||
updateIdentities,
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
@@ -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,290 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtemp, cp, rm } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { sanitizeHost } from '../domain/host';
|
||||
import type { ConnectionLog, Host, ManagedSource } from '../domain/models';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../domain/groupConfig';
|
||||
import type { HostInteractionEvent, HostInteractionPrompt, VaultHostSelectionResult, VaultHostActionResult, VaultHostRef } from '@netcatty/plugin-contract';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
test('installed sample entrypoint uses real SDK, source authority, signed host dialogs and renderer action guards', { timeout: 45_000 }, async (t) => {
|
||||
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', 'HTMLButtonElement', 'Element', 'SVGElement', '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('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
|
||||
const { default: React, act } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { PluginHostActionHost } = await import('./plugins/PluginHostActionHost');
|
||||
const { handleConnectToHostImpl } = await import('../application/app/AppHandlers');
|
||||
const runtime = await import('../application/state/appRuntimeBridge');
|
||||
const { registerAppHandlers } = await import('../application/app/appHandlersBridge');
|
||||
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
|
||||
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'netcatty-host-entry-'));
|
||||
const listeners = new Set<(event: unknown) => void>();
|
||||
const prompts: HostInteractionPrompt[] = [];
|
||||
const sender = Object.assign(new EventEmitter(), { id: 77, isDestroyed: () => false, send(_channel: string, event: HostInteractionEvent) { if (!('closed' in event)) prompts.push(event); for (const listener of listeners) listener(event); } });
|
||||
const owner = Object.assign(new EventEmitter(), { webContents: sender, isFocused: () => true, isVisible: () => true, isDestroyed: () => false });
|
||||
let locked = false;
|
||||
let onLock: ((state: { locked: boolean }) => void) | undefined;
|
||||
const service = createPluginHostService({
|
||||
app: { getAppPath: () => process.cwd(), getPath: () => directory, getVersion: () => '0.0.0' }, electron: {}, env: {},
|
||||
getInteractionWindow: () => owner, isAppLocked: () => locked,
|
||||
subscribeAppLock: (callback: typeof onLock) => { onLock = callback; return () => {}; },
|
||||
requestPermissionDecision: async (request: { requestId: string; permission: string }) => ({ requestId: request.requestId, decision: 'allow', scope: ['vault.metadata', 'vault.write', 'ui.interact'].includes(request.permission) ? 'once' : 'application' }),
|
||||
});
|
||||
let requestClock = Date.now();
|
||||
service.interactionService.clock = () => requestClock;
|
||||
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime;
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
onPluginInteraction(callback: (event: unknown) => void) { listeners.add(callback); return () => listeners.delete(callback); },
|
||||
respondPluginInteraction: (response: unknown) => service.interactionService.respond(sender, response),
|
||||
} });
|
||||
const first = sanitizeHost({ id: 'synthetic-first', label: 'Synthetic first', hostname: 'first.invalid', username: 'test', port: 22, protocol: 'ssh', os: 'linux', tags: ['synthetic'], group: 'Synthetic', password: 'synthetic-password', startupCommand: 'synthetic-script' });
|
||||
const second = sanitizeHost({ ...first, id: 'synthetic-second', label: 'Synthetic second', hostname: 'second.invalid', password: undefined, startupCommand: undefined });
|
||||
let hosts: Host[] = [first, second];
|
||||
let persisted = hosts.map((host) => structuredClone(host));
|
||||
let beforeCommit: (() => Promise<void>) | undefined;
|
||||
let saves = 0;
|
||||
const connections: Host[] = [];
|
||||
const vault = {
|
||||
hosts, customGroups: ['Synthetic'], groupConfigs: [], snippets: [], managedSources: [] as ManagedSource[], isInitialized: true,
|
||||
readCurrentHostActionState: () => ({ hosts, groupConfigs: vault.groupConfigs, snippets: vault.snippets, managedSources: vault.managedSources }),
|
||||
async readPersistedHosts() { return persisted.map((host) => structuredClone(host)); },
|
||||
async commitVaultImportTransaction(next: Host[], groups: (groups: string[]) => string[], sources: (sources: ManagedSource[]) => ManagedSource[], _configs: unknown, _expected: unknown, _lock: unknown, authorize: () => Promise<void>) {
|
||||
const nextSources = sources(vault.managedSources);
|
||||
await beforeCommit?.();
|
||||
await authorize();
|
||||
saves++; hosts = next; persisted = next.map((host) => structuredClone(host));
|
||||
return { status: 'persisted', groups: groups(vault.customGroups), sources: nextSources, groupConfigs: [] };
|
||||
},
|
||||
};
|
||||
const publish = () => {
|
||||
runtime.registerAppVaultRuntime(vault as never);
|
||||
runtime.registerAppAppLockRuntime({ initialized: true, locked } as never);
|
||||
};
|
||||
publish();
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
|
||||
const waitFor = async (predicate: () => boolean) => {
|
||||
for (let count = 0; count < 80; count++) { await act(async () => { await settle(); }); if (predicate()) return; }
|
||||
assert.fail(`Expected host UI state; current text: ${document.body.textContent}`);
|
||||
};
|
||||
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 (name: string) => {
|
||||
requestClock += 60_001;
|
||||
let promise!: Promise<VaultHostSelectionResult | VaultHostActionResult>;
|
||||
await act(async () => { promise = service.contributionService.executeCommand(`com.netcatty.host-actions.${name}`); void promise.catch(() => {}); await settle(); });
|
||||
await waitFor(() => document.querySelector('[role="dialog"]') !== null);
|
||||
return { promise };
|
||||
};
|
||||
const chooseGroup = async () => {
|
||||
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
|
||||
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
|
||||
};
|
||||
try {
|
||||
await service.manager.initialize();
|
||||
const packageRoot = path.join(directory, 'source');
|
||||
await cp(new URL('../examples/plugins/host-actions/', import.meta.url), packageRoot, { recursive: true });
|
||||
const { buildPluginPackage } = await import('@netcatty/plugin-cli');
|
||||
const archive = path.join(directory, 'fixture.ncpkg');
|
||||
await buildPluginPackage(packageRoot, archive);
|
||||
await service.manager.install(archive, { enable: true });
|
||||
service.interactionService.setReady(sender, true);
|
||||
await act(async () => root.render(<I18nProvider locale="en"><PluginHostActionHost /></I18nProvider>));
|
||||
|
||||
let selectedTarget: VaultHostRef;
|
||||
await t.test('group selection shares only explicit current members and grants no connection', async () => {
|
||||
const run = await begin('select');
|
||||
await click(document.querySelector<HTMLElement>('[role="option"] button[aria-pressed]')!);
|
||||
hosts = [...hosts, sanitizeHost({ ...second, id: 'synthetic-future', hostname: 'future.invalid' })];
|
||||
persisted = hosts.map((host) => structuredClone(host));
|
||||
await click(Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent?.startsWith('Continue ('))!);
|
||||
const result = await run.promise;
|
||||
assert.ok('hosts' in result);
|
||||
assert.equal(result.hosts.length, 2); assert.equal(result.groups[0].members.length, 2);
|
||||
assert.equal(result.hosts.some((host) => host.metadata.hostname === 'future.invalid'), false);
|
||||
assert.equal(result.hosts[0].target.scope, 'metadata');
|
||||
selectedTarget = result.hosts[0].target;
|
||||
for (const text of ['synthetic-password', 'synthetic-script', 'resourceId', 'private-session-id']) assert.equal(JSON.stringify(result).includes(text), false);
|
||||
assert.equal(connections.length, 0); assert.equal(saves, 0);
|
||||
});
|
||||
|
||||
await t.test('registered boundaries reject AI text, caller owners, tampered targets and stale expected revisions', async () => {
|
||||
const identity = service.runtimeSupervisor.getRuntimeIdentity('com.netcatty.host-actions');
|
||||
const routes = service.rpcRegistry.createRoutes({ ...identity, manifest: service.database.getActivePlugin(identity.pluginId).manifest });
|
||||
const transport = { signal: new AbortController().signal };
|
||||
for (const target of [
|
||||
{ handle: 'text-only', version: 'v1', scope: 'host' },
|
||||
{ ...selectedTarget, handle: 'synthetic-tampered-selection-handle-0000' },
|
||||
]) await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target, expectedRevision: selectedTarget.revision }, transport), /invalid|expired|changed/);
|
||||
await assert.rejects(routes.requestHandlers['vault.requestConnect']({ target: selectedTarget, expectedRevision: 'stale' }, transport), /expired|changed/);
|
||||
await assert.rejects(routes.requestHandlers['vault.requestHostDraft']({ privateOwner: 'spoof', draft: { hostname: 'synthetic.invalid' } }, transport), /invalid/);
|
||||
assert.equal(service.interactionService.pending.size, 0); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('cancel creates no draft or connection', async () => {
|
||||
const run = await begin('create'); await click(button('Cancel'));
|
||||
assert.deepEqual(await run.promise, { cancelled: true }); assert.equal(saves, 0); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('reviewed create saves only the synthetic SSH draft', async () => {
|
||||
const run = await begin('create');
|
||||
assert.match(document.body.textContent ?? '', /Changes to save/);
|
||||
await click(button('Save Host'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'saved' });
|
||||
assert.equal(saves, 1);
|
||||
const saved = hosts.find((host) => host.hostname === 'synthetic.invalid')!;
|
||||
assert.equal(saved.protocol, 'ssh'); assert.equal(saved.password, undefined); assert.equal(saved.startupCommand, undefined);
|
||||
});
|
||||
|
||||
for (const change of ['remove', 'replace'] as const) await t.test(`${change} after selection prevents a stale edit`, async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => document.body.textContent?.includes('Changes to save') === true);
|
||||
hosts = change === 'remove' ? [second] : [{ ...first }, second];
|
||||
await click(button('Save Host'));
|
||||
await assert.rejects(run.promise, /changed|cancelled|complete/);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 0);
|
||||
});
|
||||
|
||||
await t.test('connection confirmation discloses saved scripts before dispatch and returns no session', async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const run = await begin('connect'); await chooseGroup();
|
||||
await waitFor(() => document.body.textContent?.includes('Saved startup, connect or output scripts may run') === true);
|
||||
assert.equal(connections.length, 0); assert.equal(document.body.textContent?.includes('synthetic-script'), false);
|
||||
await click(button('Connect'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
|
||||
assert.equal(connections.length, 1); assert.equal(connections[0], first);
|
||||
});
|
||||
|
||||
await t.test('Vault lock cancels a pending connection', async () => {
|
||||
const run = await begin('connect'); await chooseGroup();
|
||||
await waitFor(() => button('Connect') !== undefined);
|
||||
await act(async () => { locked = true; publish(); onLock?.({ locked: true }); await settle(); });
|
||||
await assert.rejects(run.promise, /cancelled/); assert.equal(connections.length, 1);
|
||||
locked = false; publish();
|
||||
});
|
||||
|
||||
await t.test('revoke during asynchronous preparation prevents the write', async () => {
|
||||
hosts = [first, second]; persisted = hosts.map((host) => structuredClone(host));
|
||||
let release!: () => void; let preparing = false;
|
||||
beforeCommit = async () => { preparing = true; await new Promise<void>((resolve) => { release = resolve; }); };
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => button('Save Host') !== undefined); await click(button('Save Host'));
|
||||
await waitFor(() => preparing);
|
||||
await act(async () => { service.permissionEngine.revokeApplication('com.netcatty.host-actions', 'vault.write'); release(); await settle(); });
|
||||
await assert.rejects(run.promise, /revoked|cancelled/); assert.equal(saves, 1); beforeCommit = undefined;
|
||||
});
|
||||
|
||||
await t.test('source replacement during review cannot use the old action authority', async () => {
|
||||
const run = await begin('create');
|
||||
const original = service.database.getRetainedPluginSource;
|
||||
service.database.getRetainedPluginSource = () => ({ id: 'changed-source', kind: 'local', origin: null });
|
||||
try { await click(button('Save Host')); await assert.rejects(run.promise, /source binding/); }
|
||||
finally { service.database.getRetainedPluginSource = original; }
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 1);
|
||||
});
|
||||
|
||||
await t.test('managed A to B stays in the Vault editor and cannot acquire file-write effects', async () => {
|
||||
const candidate = sanitizeHost({ ...first, group: 'A', managedSourceId: 'synthetic-source-a' });
|
||||
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
|
||||
vault.managedSources = [
|
||||
{ id: 'synthetic-source-a', type: 'ssh_config', filePath: '/synthetic/a.conf', groupName: 'A', lastSyncedAt: 0 },
|
||||
{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'B', lastSyncedAt: 0 },
|
||||
];
|
||||
const run = await begin('edit'); await chooseGroup();
|
||||
await waitFor(() => button('Save Host') !== undefined);
|
||||
const group = Array.from(document.querySelectorAll('label')).find((label) => label.querySelector('span')?.textContent === 'Group')?.querySelector('input');
|
||||
assert.ok(group);
|
||||
const setValue = Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!;
|
||||
await act(async () => { setValue.call(group, 'B'); group.dispatchEvent(new dom.window.Event('input', { bubbles: true })); await settle(); });
|
||||
assert.match(document.querySelector('[role="dialog"]')?.textContent ?? '', /"A"\s*\u2192\s*"B"/u);
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
|
||||
assert.equal(button('Save Host').disabled, true);
|
||||
await click(button('Save Host')); assert.equal(saves, 1);
|
||||
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
|
||||
assert.equal(hosts[0].group, 'A'); assert.equal(hosts[0].managedSourceId, 'synthetic-source-a');
|
||||
vault.managedSources = [];
|
||||
});
|
||||
|
||||
await t.test('creating into a managed group is explicitly unavailable in the small draft UI', async () => {
|
||||
vault.managedSources = [{ id: 'synthetic-source-b', type: 'ssh_config', filePath: '/synthetic/b.conf', groupName: 'Samples', lastSyncedAt: 0 }];
|
||||
const run = await begin('create');
|
||||
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /Managed SSH config changes require the Vault host editor/);
|
||||
assert.equal(button('Save Host').disabled, true);
|
||||
await click(button('Cancel')); assert.deepEqual(await run.promise, { cancelled: true });
|
||||
assert.equal(saves, 1); vault.managedSources = [];
|
||||
});
|
||||
|
||||
for (const transport of [
|
||||
{ name: 'Mosh', expected: 'mosh', overrides: { moshEnabled: true } },
|
||||
{ name: 'ET', expected: 'et', overrides: { etEnabled: true } },
|
||||
{ name: 'implicit localhost shell', expected: 'local', overrides: { hostname: 'localhost' } },
|
||||
] as const) await t.test(`${transport.name} confirmation matches the real connection handler`, async () => {
|
||||
const candidate = sanitizeHost({ ...second, id: `synthetic-${transport.expected}`, label: `Synthetic ${transport.expected}`, ...transport.overrides });
|
||||
hosts = [candidate]; persisted = hosts.map((host) => structuredClone(host));
|
||||
const dispatched: Host[] = [];
|
||||
const logs: Array<Pick<ConnectionLog, 'protocol'>> = [];
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => handleConnectToHostImpl(() => ({
|
||||
resolveEffectiveHost: (saved: Host) => applyGroupDefaults(saved, saved.group ? resolveGroupDefaults(saved.group, vault.groupConfigs) : {}),
|
||||
discoveredShells: [], terminalSettings: {}, resolveShellSetting: () => null,
|
||||
systemInfoRef: { current: { username: 'synthetic-local-user', hostname: 'synthetic-device' } },
|
||||
keys: [], identities: [], resolveHostAuth: () => ({ username: 'synthetic-user' }),
|
||||
connectToHost: (resolved: Host) => { dispatched.push(resolved); return 'synthetic-transport-session'; },
|
||||
addConnectionLog: (entry: Pick<ConnectionLog, 'protocol'>) => logs.push(entry),
|
||||
}), host) });
|
||||
const run = await begin('connect');
|
||||
try {
|
||||
await chooseGroup(); await waitFor(() => button('Connect') !== undefined);
|
||||
const confirmation = document.querySelector('[role="dialog"]')?.textContent ?? '';
|
||||
assert.match(confirmation, new RegExp(`\\(${transport.expected}\\)`));
|
||||
assert.equal(confirmation.includes('Local execution: this opens a shell on this device.'), transport.expected === 'local');
|
||||
if (transport.expected === 'local') assert.doesNotMatch(confirmation, /test@localhost:22/u);
|
||||
assert.equal(dispatched.length, 0);
|
||||
await click(button('Connect'));
|
||||
assert.deepEqual(await run.promise, { cancelled: false, status: 'requested' });
|
||||
assert.equal(dispatched.length, 1);
|
||||
assert.equal(logs[0].protocol, transport.expected);
|
||||
} finally {
|
||||
if (button('Cancel')) await click(button('Cancel'));
|
||||
await run.promise.catch(() => {});
|
||||
registerAppHandlers({ handleConnectToHost: (host: Host) => { connections.push(host); return 'private-session-id'; } });
|
||||
}
|
||||
});
|
||||
|
||||
await t.test('runtime removal rejects old signed replies', async () => {
|
||||
const run = await begin('create');
|
||||
const old = prompts.at(-1)!;
|
||||
await act(async () => { await service.manager.setEnabled('com.netcatty.host-actions', false); await settle(); });
|
||||
await assert.rejects(run.promise);
|
||||
assert.equal(await service.interactionService.respond(sender, { requestId: old.requestId, actionToken: old.actionToken, action: 'submit', draft: { hostname: 'late.invalid' } }), false);
|
||||
assert.equal(saves, 1); assert.equal(connections.length, 1);
|
||||
});
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); });
|
||||
await service.manager.shutdown();
|
||||
runtime.registerAppVaultRuntime(null); runtime.registerAppAppLockRuntime(null); registerAppHandlers(null);
|
||||
dom.window.close(); await rm(directory, { recursive: true, force: true });
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { PluginHostDraftPreview } from './plugins/PluginHostDraftPreview';
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment } from './test-support/renderReactDom';
|
||||
import type { Host } from '../domain/models';
|
||||
import type { PluginHostDraftFields } from '../domain/pluginHostActions';
|
||||
|
||||
test('host-owned draft preview shows a field diff and submits only reviewed editable fields', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const host: Host = { id: 'synthetic', label: 'Before', hostname: 'host.invalid', username: 'test', tags: [], os: 'linux', password: 'synthetic-password', startupCommand: 'synthetic-script' };
|
||||
const submitted: PluginHostDraftFields[] = [];
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview before={host} draft={{ label: 'After' }} busy={false} onConfirm={(draft) => submitted.push(draft)} /></I18nProvider>);
|
||||
assert.equal(dom.document.querySelectorAll('input').length, 3);
|
||||
assert.match(renderer.container.textContent ?? '', /Before.*After/u);
|
||||
assert.equal(renderer.container.innerHTML.includes('synthetic-password'), false);
|
||||
assert.equal(renderer.container.innerHTML.includes('synthetic-script'), false);
|
||||
const save = dom.document.querySelector('button');
|
||||
assert.ok(save);
|
||||
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
assert.deepEqual(submitted, [{ label: 'After', group: '', tags: [] }]);
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
|
||||
test('invalid create draft cannot be confirmed', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><PluginHostDraftPreview draft={{ hostname: 'invalid host' }} busy={false} onConfirm={() => { throw new Error('Invalid draft was submitted'); }} /></I18nProvider>);
|
||||
assert.ok(dom.document.querySelector('[role="alert"]'));
|
||||
const save = dom.document.querySelector('button');
|
||||
assert.ok(save?.disabled);
|
||||
await dispatchDomEvent(save, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
} finally { await renderer.unmount(); dom.cleanup(); }
|
||||
});
|
||||
@@ -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); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('terminal extension pages use actual split panes, focus and session ownership without sharing session data', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'SVGElement', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
|
||||
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
|
||||
}
|
||||
install('PointerEvent', dom.window.MouseEvent);
|
||||
install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
|
||||
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
let rectX = 200;
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = () => new dom.window.DOMRect(rectX, 100, 400, 300);
|
||||
const { default: React, act, useCallback, useRef, useState } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const { activeTabStore } = await import('../application/state/activeTabStore');
|
||||
const { sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT } = await import('../application/state/sidePanelLiveStore');
|
||||
const { useTerminalSidePanelLayoutState } = await import('../application/state/useTerminalSidePanelLayoutState');
|
||||
const { usePluginTerminalSidebar } = await import('../application/state/usePluginTerminalSidebar');
|
||||
const { PluginTerminalSidebarLauncher, PluginTerminalSidebarPageHost } = await import('./plugins/PluginTerminalSidebar');
|
||||
const { SidePanelLayoutTree } = await import('./terminalLayer/TerminalLayerSidePanelSection');
|
||||
const { PersistentSidePanelPortal } = await import('./terminalLayer/terminalLayerSidePanelSlots');
|
||||
const { collectSidePanelPanes } = await import('../domain/sidePanelLayout');
|
||||
type Tool = import('../domain/sidePanelLayout').SidePanelTool;
|
||||
let selectedSession = 'synthetic-session-a';
|
||||
const getSession = () => selectedSession;
|
||||
const a = { id: 'com.example.sidebar.first', title: 'First page', location: 'terminal-sidebar', entry: 'page.html', visible: true };
|
||||
const b = { ...a, id: 'com.example.sidebar.second', title: 'Second page' };
|
||||
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.sidebar', displayName: 'Synthetic plugin', version: '1.0.0', commands: [], keybindings: [], menus: [], settings: [], views: [a, b] }] };
|
||||
const changed = new Set<() => void>(), focusListeners = new Set<(event: { instanceId: string }) => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], focus: string[] = [], bounds: Array<{ instanceId: string; x: number }> = [];
|
||||
const visible = new Map<string, boolean>();
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; }, async getPluginContributions() { return snapshot; },
|
||||
onPluginContributionsChanged(fn: () => void) { changed.add(fn); return () => { changed.delete(fn); }; },
|
||||
onPluginViewClosed() { return () => {}; },
|
||||
onPluginViewFocused(fn: (event: { instanceId: string }) => void) { focusListeners.add(fn); return () => { focusListeners.delete(fn); }; },
|
||||
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return { instanceId: payload.instanceId! }; },
|
||||
async closePluginView(id: string) { closes.push(id); },
|
||||
async setPluginViewBounds(id: string, rect: { x: number }) { bounds.push({ instanceId: id, x: rect.x }); },
|
||||
async setPluginViewVisibility(id: string, value: boolean) { visible.set(id, value); },
|
||||
async focusPluginView(event: { instanceId: string }) { focus.push(event.instanceId); for (const fn of focusListeners) fn(event); },
|
||||
} });
|
||||
let split!: () => void;
|
||||
function Fixture() {
|
||||
const state = useTerminalSidePanelLayoutState(); const latest = useRef(state); latest.current = state;
|
||||
const model = usePluginTerminalSidebar({ layouts: state.sidePanelLayouts, getSession, isTerminalTab: (id) => id === 'workspace-synthetic' || id === 'synthetic-local-terminal',
|
||||
select: (tool) => state.selectSidePanelToolForTab(activeTabStore.getActiveTabId(), tool),
|
||||
withdraw: (tabId, paneId, tool) => {
|
||||
const layout = state.sidePanelLayoutsRef.current.get(tabId);
|
||||
if (layout && collectSidePanelPanes(layout.root).some((pane) => pane.id === paneId && pane.tool === tool)) state.closePane(tabId, paneId);
|
||||
} });
|
||||
const [hosts, setHosts] = useState(new Map<Tool, HTMLElement>()); const parking = useRef<HTMLDivElement>(null);
|
||||
const register = useCallback((tool: Tool, host: HTMLElement | null) => setHosts((prior) => {
|
||||
if ((prior.get(tool) ?? null) === host) return prior;
|
||||
const next = new Map(prior); if (host) next.set(tool, host); else next.delete(tool); return next;
|
||||
}), []);
|
||||
const focusPane = useCallback((id: string) => latest.current.focusPane(activeTabStore.getActiveTabId(), id), []);
|
||||
const closePane = useCallback((id: string) => { latest.current.closePane(activeTabStore.getActiveTabId(), id); }, []);
|
||||
const resize = useCallback(() => {}, []);
|
||||
const layout = model.tabId ? state.sidePanelLayouts.get(model.tabId) : null;
|
||||
const panes = layout ? collectSidePanelPanes(layout.root) : [];
|
||||
split = () => state.splitPane('workspace-synthetic', `plugin-view:${b.id}`, 'vertical', { paneId: 'second-pane', splitId: 'plugin-split' }, 600);
|
||||
return <><PluginTerminalSidebarLauncher sessionId={selectedSession} />
|
||||
{layout && <SidePanelLayoutTree node={layout.root} layout={layout} paneCount={panes.length} labels={new Map(model.pages.map((page) => [page.tool, page.title]))}
|
||||
onClose={closePane} onFocus={focusPane} onHostChange={register} onResize={resize} separator="#777" accent="#4488cc" closePaneLabel="Close pane" resizeLabel="Resize" />}
|
||||
<div ref={parking} hidden aria-hidden="true" />
|
||||
{model.sessionId && panes.map((pane) => {
|
||||
const page = model.pages.find((item) => item.tool === pane.tool); const target = hosts.get(pane.tool) ?? parking.current;
|
||||
if (!page || !target) return null;
|
||||
return <PersistentSidePanelPortal key={`${pane.id}:${model.sessionId}`} portalKey={`plugin-${pane.id}`} target={target}>
|
||||
<PluginTerminalSidebarPageHost page={page} tabId={model.tabId!} sessionId={model.sessionId} getSession={getSession} adapter={model.contributions}
|
||||
focusOnReveal={layout?.focusedPaneId === pane.id} focused={() => focusPane(pane.id)} unavailable={() => closePane(pane.id)} />
|
||||
</PersistentSidePanelPortal>;
|
||||
})}</>;
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!); const settle = () => new Promise((resolve) => setTimeout(resolve, 40));
|
||||
try {
|
||||
activeTabStore.setActiveTabId('workspace-synthetic'); sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
|
||||
const firstButton = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('First page'))!;
|
||||
await act(async () => { firstButton.click(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
|
||||
assert.ok(document.querySelector('[data-pane-tool] [data-plugin-view-slot="terminal-sidebar"]'));
|
||||
assert.equal(opens.length, 1); assert.deepEqual(opens[0].context, { 'netcatty.surface': 'terminal-sidebar' });
|
||||
await act(async () => { split(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 2);
|
||||
assert.equal(opens.length, 2);
|
||||
const firstId = opens.find((item) => item.viewId === a.id)!.instanceId!;
|
||||
await act(async () => { for (const fn of focusListeners) fn({ instanceId: firstId }); await settle(); });
|
||||
assert.equal(document.querySelector(`[data-pane-tool="plugin-view:${a.id}"]`)?.getAttribute('data-focused'), 'true');
|
||||
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
|
||||
await act(async () => { document.body.append(dialog); await settle(); });
|
||||
assert.ok(opens.every((item) => visible.get(item.instanceId!) === false));
|
||||
focus.length = 0;
|
||||
await act(async () => { dialog.remove(); await settle(); });
|
||||
assert.ok(focus.length > 0); assert.ok(focus.every((id) => id === firstId), 'restoring panes focuses only the host-selected pane');
|
||||
const old = opens.map((item) => item.instanceId!);
|
||||
await act(async () => { selectedSession = 'synthetic-session-b'; sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, focusedSessionId: selectedSession });
|
||||
assert.ok(old.every((id) => closes.includes(id)), 'workspace focus cancels old native owners synchronously'); await settle(); });
|
||||
assert.equal(opens.length, 4);
|
||||
assert.notEqual(opens[0].scopeId, opens[2].scopeId);
|
||||
assert.ok(opens.every((item) => JSON.stringify(item.context) === JSON.stringify({ 'netcatty.surface': 'terminal-sidebar' })));
|
||||
await act(async () => { rectX = 260; snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [{ ...a, visible: false }, b] }] }; for (const fn of changed) fn(); await settle(); });
|
||||
assert.equal(document.querySelectorAll('[data-section="terminal-side-panel-pane"]').length, 1);
|
||||
assert.equal(document.querySelector('[data-pane-tool]')?.getAttribute('data-pane-tool'), `plugin-view:${b.id}`);
|
||||
assert.ok(bounds.some((item) => item.x === 260), 'portal relocation updates native geometry');
|
||||
const last = opens.at(-1)!.instanceId!;
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); assert.ok(closes.includes(last)); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-terminal-sidebar]'), null);
|
||||
await act(async () => { selectedSession = 'synthetic-local-terminal'; activeTabStore.setActiveTabId(selectedSession); await settle(); });
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Extension pages"]')!.click(); await settle(); });
|
||||
const localPage = [...document.querySelectorAll('button')].find((button) => button.textContent?.includes('Second page'))!;
|
||||
await act(async () => { localPage.click(); await settle(); });
|
||||
assert.ok(document.querySelector('[data-plugin-view-slot="terminal-sidebar"]'), 'standalone Local Terminal is a valid navigation owner');
|
||||
assert.deepEqual(opens.at(-1)!.context, { 'netcatty.surface': 'terminal-sidebar' });
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
test('Vault navigation mounts a real content slot, yields to host dialogs, and withdraws stale pages', async () => {
|
||||
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'CustomEvent', 'Event']) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value: key === 'window' ? dom.window : dom.window[key as keyof Window] });
|
||||
}
|
||||
for (const [key, value] of Object.entries({ getComputedStyle: dom.window.getComputedStyle.bind(dom.window),
|
||||
requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), IS_REACT_ACT_ENVIRONMENT: true })) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
}
|
||||
dom.window.HTMLElement.prototype.getBoundingClientRect = () => ({ x: 200, y: 100, width: 640, height: 480, top: 100, left: 200, right: 840, bottom: 580, toJSON() {} });
|
||||
const { default: React, act, useState } = await import('react');
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
const { usePluginVaultPages } = await import('../application/state/usePluginVaultPages');
|
||||
const { PluginVaultNavigation, PluginVaultPage } = await import('./plugins/PluginVaultPages');
|
||||
const { activeTabStore } = await import('../application/state/activeTabStore');
|
||||
const { requestOpenPluginView } = await import('../application/state/usePluginViewLifecycle');
|
||||
const { I18nProvider } = await import('../application/i18n/I18nProvider');
|
||||
const page = { id: 'com.example.page.main', title: 'Synthetic page', location: 'vault-page', entry: 'page.html', visible: true };
|
||||
let snapshot = { locale: 'en', plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] };
|
||||
let changed: (() => void) | undefined;
|
||||
const listeners = new Set<(event: NetcattyPluginViewClosedEvent) => void>();
|
||||
const opens: NetcattyPluginViewOpenRequest[] = [], closes: string[] = [], visibility: boolean[] = [];
|
||||
let defer: ((value: { instanceId: string }) => void) | undefined;
|
||||
let deferOpen = false;
|
||||
let delayLayout = false;
|
||||
const rejectOldLayout: Array<(error: Error) => void> = [];
|
||||
const layoutResult = () => delayLayout ? new Promise<void>((_resolve, reject) => { rejectOldLayout.push(reject); }) : Promise.resolve();
|
||||
Object.assign(dom.window, { netcatty: {
|
||||
async getPluginRuntimeStatus() { return { available: true }; },
|
||||
async getPluginContributions() { return snapshot; },
|
||||
onPluginContributionsChanged(fn: () => void) { changed = fn; return () => { changed = undefined; }; },
|
||||
onPluginViewClosed(fn: (event: NetcattyPluginViewClosedEvent) => void) { listeners.add(fn); return () => { listeners.delete(fn); }; },
|
||||
async openPluginView(payload: NetcattyPluginViewOpenRequest) { opens.push(payload); return deferOpen ? new Promise<{ instanceId: string }>((resolve) => { defer = resolve; }) : { instanceId: payload.instanceId! }; },
|
||||
async closePluginView(id: string) { closes.push(id); }, async setPluginViewBounds() { await layoutResult(); },
|
||||
async setPluginViewVisibility(_id: string, visible: boolean) { visibility.push(visible); await layoutResult(); },
|
||||
} });
|
||||
let selectSection!: (section: 'hosts' | `plugin-vault:${string}`) => void;
|
||||
function Fixture() {
|
||||
const [section, select] = useState<'hosts' | `plugin-vault:${string}`>('hosts'); selectSection = select;
|
||||
const model = usePluginVaultPages({ locale: 'en', section, select });
|
||||
return <><aside><button data-plugin-vault-fallback onClick={() => select('hosts')}>Hosts</button><PluginVaultNavigation model={model} collapsed={false} /></aside>
|
||||
<main data-section="vault-main">{model.selected ? <PluginVaultPage key={model.selected.key} model={model} /> : <p>Built-in hosts</p>}</main></>;
|
||||
}
|
||||
const root = createRoot(document.getElementById('root')!);
|
||||
const settle = () => new Promise((resolve) => setTimeout(resolve, 35));
|
||||
const clickPage = async () => { await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Synthetic page — Synthetic plugin"]')!.click(); await settle(); }); };
|
||||
try {
|
||||
activeTabStore.setActiveTabId('vault');
|
||||
await act(async () => { root.render(<I18nProvider locale="en"><Fixture /></I18nProvider>); await settle(); });
|
||||
assert.equal(opens.length, 0, 'catalog enumeration does not activate the plugin');
|
||||
await clickPage();
|
||||
assert.ok(document.querySelector('[data-section="vault-main"] [data-plugin-view-slot="vault-page"]'));
|
||||
assert.equal(opens.length, 1);
|
||||
assert.deepEqual(opens[0].context, { 'netcatty.surface': 'vault-page' }, 'no Vault/session data accompanies navigation');
|
||||
assert.equal(visibility.at(-1), true);
|
||||
const dialog = document.createElement('div'); dialog.setAttribute('role', 'dialog'); dialog.setAttribute('aria-modal', 'true');
|
||||
await act(async () => { document.body.append(dialog); await settle(); });
|
||||
assert.equal(visibility.at(-1), false, 'native content yields to host security/settings dialogs');
|
||||
await act(async () => { dialog.remove(); await settle(); });
|
||||
assert.equal(visibility.at(-1), true);
|
||||
await act(async () => { activeTabStore.setActiveTabId('terminal:synthetic'); assert.ok(closes.includes(opens[0].instanceId!)); await settle(); });
|
||||
await act(async () => { activeTabStore.setActiveTabId('vault'); await settle(); });
|
||||
assert.equal(opens.length, 2);
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [] }; changed?.(); await settle(); });
|
||||
await act(async () => { await settle(); });
|
||||
assert.ok(closes.includes(opens[1].instanceId!));
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null);
|
||||
assert.equal(document.activeElement?.textContent, 'Hosts');
|
||||
await act(async () => { selectSection('plugin-vault:missing'); await settle(); });
|
||||
assert.match(document.querySelector('main')!.textContent!, /Built-in hosts/);
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ id: 'com.example.page', version: '1.0.0', displayName: 'Synthetic plugin', commands: [], keybindings: [], menus: [], settings: [], views: [page] }] }; changed?.(); await settle(); });
|
||||
deferOpen = true;
|
||||
await act(async () => { requestOpenPluginView({ viewId: page.id }); await settle(); });
|
||||
assert.equal(opens.length, 3);
|
||||
await act(async () => { selectSection('hosts'); await settle(); });
|
||||
assert.ok(closes.includes(opens[2].instanceId!), 'cancel reserved instance before its open response');
|
||||
await act(async () => { defer!({ instanceId: opens[2].instanceId! }); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]'), null, 'late open cannot restore withdrawn page');
|
||||
assert.ok(closes.filter((id) => id === opens[2].instanceId).length >= 2, 'late native response is also closed');
|
||||
deferOpen = false;
|
||||
const second = { ...page, id: 'com.example.page.second', title: 'Second page' };
|
||||
await act(async () => { snapshot = { ...snapshot, plugins: [{ ...snapshot.plugins[0], views: [page, second] }] }; changed?.(); await settle(); });
|
||||
delayLayout = true;
|
||||
await clickPage();
|
||||
assert.ok(rejectOldLayout.length >= 2, 'old page has both bounds and visibility IPC in flight');
|
||||
await act(async () => { selectSection('hosts'); await settle(); });
|
||||
delayLayout = false;
|
||||
await act(async () => { document.querySelector<HTMLButtonElement>('button[aria-label="Second page — Synthetic plugin"]')!.click(); await settle(); });
|
||||
await act(async () => { for (const reject of rejectOldLayout) reject(new Error('old page layout failed')); await settle(); });
|
||||
assert.equal(document.querySelector('[data-plugin-vault-page]')?.getAttribute('aria-label'), 'Second page', 'old bounds/visibility failures cannot withdraw the newly selected page');
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); await settle(); }); dom.window.close();
|
||||
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export interface SelectHostPanelContentProps {
|
||||
/** Preferred multi-select path for host/group toggles (selection resolves to host ids). */
|
||||
onSelectionChange?: (selectedHostIds: string[]) => void;
|
||||
onConfirm: () => void;
|
||||
confirmDisabled?: boolean;
|
||||
onNewHost?: () => void;
|
||||
availableKeys?: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
@@ -79,6 +80,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
onSelect,
|
||||
onSelectionChange,
|
||||
onConfirm,
|
||||
confirmDisabled = false,
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
identities = [],
|
||||
@@ -643,7 +645,7 @@ export const SelectHostPanelContent: React.FC<SelectHostPanelContentProps> = ({
|
||||
<div className="px-4 py-3 border-t border-border/60 shrink-0">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={selectedHostIds.length === 0}
|
||||
disabled={confirmDisabled || selectedHostIds.length === 0}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{multiSelect
|
||||
|
||||
@@ -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();
|
||||
@@ -523,7 +528,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
|
||||
{/* min-w-0: row flex item must shrink so long Agent paths truncate inside the pane. */}
|
||||
<div className="flex-1 h-full flex flex-col min-h-0 min-w-0 bg-muted/10">
|
||||
{mountedTabs.has("application") && (
|
||||
<SettingsLazyTab value="application">
|
||||
<LazySettingsApplicationTab
|
||||
@@ -714,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} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+82
-22
@@ -243,6 +243,7 @@ import {
|
||||
import {
|
||||
canHibernateTerminalRuntimeSession,
|
||||
canHibernateTerminalRuntimeStatus,
|
||||
decideHiddenTabKeepCountAction,
|
||||
isTerminalFileTransferActive,
|
||||
resolveHibernateKeepRendererCount,
|
||||
resolveHibernatePreferWasmSerialize,
|
||||
@@ -254,6 +255,7 @@ import {
|
||||
} from "../domain/terminalHibernate";
|
||||
import { getLastChar, removeLastChar } from "../domain/serialCharMetrics";
|
||||
import { stringCellWidth } from "./terminal/autocomplete/terminalStringCellWidth";
|
||||
import { retainLeavingTabAfterEviction } from "../application/state/hiddenTabEviction";
|
||||
import { terminalHiddenRendererStore } from "../application/state/terminalHiddenRendererStore";
|
||||
import {
|
||||
wakeTerminalFromHibernate,
|
||||
@@ -522,6 +524,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hibernateAlternateScreenRef = useRef(false);
|
||||
const hibernateRetryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const fullHibernateRuntimeRef = useRef<(() => Promise<boolean>) | null>(null);
|
||||
const hibernateEvictionAttemptRef = useRef(0);
|
||||
const wakeSoftHiddenRuntimeRef = useRef<(() => void) | null>(null);
|
||||
const resumeRendererAfterCancelledHibernateUpgradeRef = useRef<(() => void) | null>(null);
|
||||
const wakeInProgressRef = useRef(false);
|
||||
@@ -2297,20 +2300,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
]);
|
||||
fullHibernateRuntimeRef.current = fullHibernateRuntime;
|
||||
|
||||
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
|
||||
if (!wakeSoftHiddenRuntimeRef.current) return;
|
||||
wakeSoftHiddenRuntimeRef.current?.();
|
||||
void fullHibernateRuntime().then(
|
||||
(completed) => {
|
||||
if (!completed) resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
},
|
||||
(error) => {
|
||||
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
|
||||
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
},
|
||||
);
|
||||
}, [fullHibernateRuntime, sessionId]);
|
||||
|
||||
const hideRuntimeOnly = useCallback(() => {
|
||||
if (hibernatedRef.current || softHiddenRef.current || !hasRuntimeRef.current) return;
|
||||
xtermRuntimeRef.current?.suspendWebglRenderer();
|
||||
@@ -2319,6 +2308,39 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
logger.info("[Terminal] Soft-hidden runtime", { sessionId });
|
||||
}, [sessionId]);
|
||||
|
||||
const upgradeSoftHiddenRuntimeToHibernate = useCallback(() => {
|
||||
const pendingEviction = terminalHiddenRendererStore.hasPendingEviction(sessionId);
|
||||
if (!wakeSoftHiddenRuntimeRef.current) {
|
||||
terminalHiddenRendererStore.settleEviction(sessionId, false);
|
||||
return;
|
||||
}
|
||||
wakeSoftHiddenRuntimeRef.current?.();
|
||||
void fullHibernateRuntime().then(
|
||||
(completed) => {
|
||||
if (!completed) {
|
||||
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
// Wake already dropped the marker. Restore the soft-hidden slot only
|
||||
// while this tab is still hidden and hibernate is still enabled.
|
||||
// Selecting the victim during the drain makes fullHibernateRuntime
|
||||
// return false because the tab is visible; soft-hiding it again
|
||||
// would hide the tab the user just opened.
|
||||
if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current) {
|
||||
hideRuntimeOnly();
|
||||
}
|
||||
}
|
||||
terminalHiddenRendererStore.settleEviction(sessionId, completed);
|
||||
},
|
||||
(error) => {
|
||||
logger.error("[Terminal] Failed to upgrade soft-hidden runtime to hibernate", { sessionId, error });
|
||||
resumeRendererAfterCancelledHibernateUpgradeRef.current?.();
|
||||
if (pendingEviction && !isVisibleRef.current && hibernateEnabledRef.current) {
|
||||
hideRuntimeOnly();
|
||||
}
|
||||
terminalHiddenRendererStore.settleEviction(sessionId, false);
|
||||
},
|
||||
);
|
||||
}, [fullHibernateRuntime, hideRuntimeOnly, sessionId]);
|
||||
|
||||
const hibernateRuntime = useCallback(() => {
|
||||
if (hibernatedRef.current || !termRef.current) return;
|
||||
|
||||
@@ -2333,8 +2355,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnected sessions never stay soft-hidden. A connected session whose
|
||||
// keep count dropped to zero takes the same upgrade. A positive count stays
|
||||
// retained until a later leave evicts overflow.
|
||||
if (softHiddenRef.current) {
|
||||
if (statusRef.current === "disconnected") {
|
||||
const retainedKeepCount = statusRef.current === "connected"
|
||||
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
|
||||
: 0;
|
||||
if (statusRef.current === "disconnected" || !(retainedKeepCount > 0)) {
|
||||
upgradeSoftHiddenRuntimeToHibernate();
|
||||
}
|
||||
return;
|
||||
@@ -2343,16 +2371,47 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const keepCount = statusRef.current === "connected"
|
||||
? resolveHibernateKeepRendererCount(terminalSettings)
|
||||
: 0;
|
||||
if (keepCount > 0 && terminalHiddenRendererStore.getSoftHiddenCount() < keepCount) {
|
||||
const keepDecision = decideHiddenTabKeepCountAction({
|
||||
keepCount,
|
||||
softHiddenCount: terminalHiddenRendererStore.getSoftHiddenOccupancy(),
|
||||
sessionId,
|
||||
candidatesOldestFirst: keepCount > 0
|
||||
? terminalHiddenRendererStore.listSoftHiddenOldestFirst()
|
||||
: [],
|
||||
});
|
||||
if (keepDecision.action === "soft-hide") {
|
||||
hideRuntimeOnly();
|
||||
return;
|
||||
}
|
||||
|
||||
if (keepCount > 0) {
|
||||
const victim = terminalHiddenRendererStore.pickEvictionCandidate(keepCount);
|
||||
if (victim && victim !== sessionId) {
|
||||
terminalHiddenRendererStore.requestEviction(victim);
|
||||
}
|
||||
if (keepDecision.action === "await-eviction") {
|
||||
const attempt = ++hibernateEvictionAttemptRef.current;
|
||||
// Reserves this tab's slot before the first await, then soft-hides only
|
||||
// if a victim hibernate completes and the slot still fits. A sync marker
|
||||
// clear is not enough: alt-screen and drain/snapshot failure restore the
|
||||
// victim, and this tab stays out of the soft-hidden set.
|
||||
void retainLeavingTabAfterEviction({
|
||||
sessionId,
|
||||
candidates: keepDecision.candidates,
|
||||
keepCount,
|
||||
// Re-read after the victim drain. The count above was captured when
|
||||
// this timer fired; a zero or tighter setting must full-hibernate.
|
||||
resolveKeepCount: () => (
|
||||
statusRef.current === "connected"
|
||||
? resolveHibernateKeepRendererCount(terminalSettingsRef.current)
|
||||
: 0
|
||||
),
|
||||
stillWantsRetention: () => (
|
||||
hibernateEvictionAttemptRef.current === attempt
|
||||
&& !isVisibleRef.current
|
||||
&& !hibernatedRef.current
|
||||
&& !softHiddenRef.current
|
||||
),
|
||||
softHide: () => hideRuntimeOnly(),
|
||||
fullHibernate: () => {
|
||||
void fullHibernateRuntime();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
void fullHibernateRuntime();
|
||||
@@ -4710,6 +4769,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isSearchOpen,
|
||||
hibernateEnabled: hibernateEnabled,
|
||||
hibernateDelayMs: resolveTerminalHibernateDelayMs(terminalSettings),
|
||||
hibernateKeepRendererCount: resolveHibernateKeepRendererCount(terminalSettings),
|
||||
fileTransferActive: hibernateFileTransferActive,
|
||||
hibernatedRef,
|
||||
softHiddenRef,
|
||||
|
||||
@@ -123,6 +123,7 @@ import {
|
||||
import { classifyDistroId, hostRestrictsExtraSshChannels, shouldProbeSessionCwd } from '../domain/host';
|
||||
import {
|
||||
collectSidePanelPanes,
|
||||
isPluginSidePanelTool,
|
||||
sidePanelLayoutHasTool,
|
||||
type SidePanelLayout,
|
||||
type SidePanelSplitDirection,
|
||||
@@ -1933,6 +1934,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
resizeSidePanelSplitForTab(tabId, splitId, sizes);
|
||||
}, [resizeSidePanelSplitForTab]);
|
||||
|
||||
const handleWithdrawPluginSidebarPane = useCallback((tabId: string, paneId: string, expectedTool: SidePanelTab) => {
|
||||
if (!isPluginSidePanelTool(expectedTool)) return;
|
||||
const layout = sidePanelLayoutsRef.current.get(tabId);
|
||||
const pane = layout && collectSidePanelPanes(layout.root).find((item) => item.id === paneId);
|
||||
if (pane?.tool !== expectedTool) return;
|
||||
setMagnifiedPane((current) => current?.tabId === tabId && current.target.kind === 'side-panel'
|
||||
&& current.target.paneId === paneId ? null : current);
|
||||
closeSidePanelPaneForTab(tabId, paneId);
|
||||
}, [closeSidePanelPaneForTab, sidePanelLayoutsRef]);
|
||||
|
||||
// Toggle SFTP from activity bar header
|
||||
const handleToggleSftpFromBar = useCallback(() => {
|
||||
handleSwitchSidePanelTab('sftp');
|
||||
@@ -2617,6 +2628,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
handleOpenAI,
|
||||
handleOpenSystem,
|
||||
handleOpenNotes,
|
||||
getActiveTerminalSessionId,
|
||||
handleSwitchSidePanelTab,
|
||||
handleWithdrawPluginSidebarPane,
|
||||
handleOpenBrowser,
|
||||
handleFocusSidePanelPane,
|
||||
handleMagnifySidePanelPane,
|
||||
|
||||
@@ -161,6 +161,7 @@ import { VaultViewLayout } from "./vault/VaultViewLayout";
|
||||
import { useVaultHostCollections } from "./vault/useVaultHostCollections";
|
||||
import { useVaultImportHandlers } from "../application/state/useVaultImportHandlers";
|
||||
import { useVaultGroupDragHandlers } from "./vault/useVaultGroupDragHandlers";
|
||||
import { usePluginVaultPages } from "../application/state/usePluginVaultPages";
|
||||
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
@@ -178,7 +179,8 @@ export type VaultSection =
|
||||
| "notes"
|
||||
| "port"
|
||||
| "knownhosts"
|
||||
| "logs";
|
||||
| "logs"
|
||||
| `plugin-vault:${string}`;
|
||||
|
||||
const haveSameHostOrderResult = (previous: Host[], next: Host[]) => {
|
||||
if (previous.length !== next.length) return false;
|
||||
@@ -365,11 +367,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onVaultFocusRequestHandled,
|
||||
terminalSettings,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const [currentSection, setCurrentSection] = useState<VaultSection>("hosts");
|
||||
const pluginVault = usePluginVaultPages({ locale, section: currentSection, select: setCurrentSection });
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedGroupPath, setSelectedGroupPath] = useState<string | null>(
|
||||
null,
|
||||
@@ -1522,6 +1525,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
ContextMenuTrigger,
|
||||
Copy,
|
||||
currentSection,
|
||||
pluginVault,
|
||||
customGroups,
|
||||
deleteGroupPath,
|
||||
deleteGroupPaths,
|
||||
|
||||
@@ -43,6 +43,26 @@ test("note content drafts stay in refs so MDX keystrokes do not rebuild the shel
|
||||
);
|
||||
});
|
||||
|
||||
test("hidden notes flush drafts on the isActive edge without rebuilding the tree", () => {
|
||||
assert.match(
|
||||
managerSource,
|
||||
/useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;\s*flushNoteDraftRef\.current\(\);\s*\}, \[isActive\]\);/,
|
||||
);
|
||||
assert.match(managerSource, /const NotesTreePane = memo\(/);
|
||||
assert.match(managerSource, /notesTreePanePropsEqual/);
|
||||
assert.match(managerSource, /prev\.selectedNoteId === next\.selectedNoteId/);
|
||||
assert.match(managerSource, /prev\.editingNoteId === next\.editingNoteId/);
|
||||
assert.match(managerSource, /prev\.editingGroupPath === next\.editingGroupPath/);
|
||||
assert.match(managerSource, /prev\.expandedGroups === next\.expandedGroups/);
|
||||
assert.match(managerSource, /<NotesTreePane/);
|
||||
assert.doesNotMatch(managerSource, /key=\{isActive\}/);
|
||||
assert.doesNotMatch(
|
||||
managerSource,
|
||||
/if \(!isActive\) return null;/,
|
||||
"hiding notes must not unmount NotesManager",
|
||||
);
|
||||
});
|
||||
|
||||
test("NotesManager teardown flush uses a stable ref under StrictMode", () => {
|
||||
assert.match(managerSource, /flushNoteDraftRef\.current = flushNoteDraft/);
|
||||
assert.match(
|
||||
@@ -73,6 +93,21 @@ test("hidden terminal notes side panel does not subscribe to notes publishes", (
|
||||
"utf8",
|
||||
);
|
||||
assert.match(slotsSource, /useNotesStore\(\{\s*enabled:\s*isVisible\s*\}\)/);
|
||||
const notesSlot = slotsSource.slice(
|
||||
slotsSource.indexOf("function SidePanelNotesSlotInner"),
|
||||
slotsSource.indexOf("export const SidePanelNotesSlot"),
|
||||
);
|
||||
assert.doesNotMatch(notesSlot, /if \(!isVisible\) return null/);
|
||||
assert.match(notesSlot, /<NotesManager/);
|
||||
const notesStoreSource = readFileSync(
|
||||
new URL("../../application/state/notesStore.ts", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
notesStoreSource,
|
||||
/enabled \? getNotesSnapshot : getEmptyNotesSnapshot/,
|
||||
"enabled:false must keep the live snapshot so reopen does not flash empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("notes manager prefetches the MDXEditor chunk when becoming active", () => {
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
isNoteFolderTreeSelected,
|
||||
isNoteEditorMode,
|
||||
normalizeNoteEditorMode,
|
||||
notesTreePanePropsEqual,
|
||||
NotesManager,
|
||||
type NotesTreePaneProps,
|
||||
} from "./NotesManager.tsx";
|
||||
|
||||
const note = (overrides: Partial<VaultNote> = {}): VaultNote => ({
|
||||
@@ -369,10 +371,40 @@ test("NotesManager flushes drafts when retained mounts become inactive", () => {
|
||||
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /isActive\?: boolean/);
|
||||
assert.match(source, /if \(isActive\) return;/);
|
||||
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;/);
|
||||
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;\s*flushNoteDraftRef\.current\(\);/);
|
||||
assert.match(source, /visibilitychange/);
|
||||
});
|
||||
|
||||
const treePaneProps = (overrides: Partial<NotesTreePaneProps> = {}): NotesTreePaneProps => ({
|
||||
notes: [note()],
|
||||
noteGroups: ["Ops"],
|
||||
selectedNoteId: "note-1",
|
||||
selectedGroup: null,
|
||||
expandedGroups: new Set(["Ops"]),
|
||||
editingNoteId: null,
|
||||
editingGroupPath: null,
|
||||
query: "",
|
||||
locale: "en",
|
||||
hosts: [],
|
||||
draggingNoteId: null,
|
||||
draggingGroupPath: null,
|
||||
creatingGroupParent: undefined,
|
||||
isSidebarMode: true,
|
||||
renderRows: () => null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("notes tree pane equality ignores render callbacks and compares selection state", () => {
|
||||
const base = treePaneProps();
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, renderRows: () => "other" }), true);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, selectedNoteId: "note-2" }), false);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, selectedGroup: "Ops" }), false);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, editingNoteId: "note-1" }), false);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, editingGroupPath: "Ops" }), false);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, expandedGroups: new Set(["Ops"]) }), false);
|
||||
assert.equal(notesTreePanePropsEqual(base, { ...base, notes: [note({ title: "Renamed" })] }), false);
|
||||
});
|
||||
|
||||
test("NotesManager tree rename allows clearing note titles", () => {
|
||||
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
|
||||
const renameBlock = source.match(/onRenameCommit=\{\(name\) => \{[\s\S]*?\}\}/)?.[0] ?? "";
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import React, { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { lazy, memo, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { useApplicationBackend } from "../../application/state/useApplicationBackend";
|
||||
import { useStoredNumber } from "../../application/state/useStoredNumber";
|
||||
@@ -357,6 +357,57 @@ export const getValidatedNoteSelectionState = (
|
||||
return null;
|
||||
};
|
||||
|
||||
export type NotesTreePaneProps = {
|
||||
notes: VaultNote[];
|
||||
noteGroups: string[];
|
||||
selectedNoteId: string | null;
|
||||
selectedGroup: string | null;
|
||||
expandedGroups: ReadonlySet<string>;
|
||||
editingNoteId: string | null;
|
||||
editingGroupPath: string | null;
|
||||
query: string;
|
||||
locale: string;
|
||||
hosts: Host[];
|
||||
draggingNoteId: string | null;
|
||||
draggingGroupPath: string | null;
|
||||
creatingGroupParent: string | null | undefined;
|
||||
isSidebarMode: boolean;
|
||||
/**
|
||||
* Row elements. Identity changes every parent render; equality ignores it so
|
||||
* a visibility flip does not rebuild the tree. Event handlers close over the
|
||||
* render that last changed one of the compared fields.
|
||||
*/
|
||||
renderRows: () => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hide/show passes a new `isActive` into NotesManager, but that flag is not a
|
||||
* tree input. Equality therefore keys off the data the rows actually paint.
|
||||
* `selected` / `editing` / `expanded` are required so a memo bailout cannot
|
||||
* freeze selection, rename, or folder expansion.
|
||||
*/
|
||||
export function notesTreePanePropsEqual(prev: NotesTreePaneProps, next: NotesTreePaneProps): boolean {
|
||||
return prev.notes === next.notes
|
||||
&& prev.noteGroups === next.noteGroups
|
||||
&& prev.selectedNoteId === next.selectedNoteId
|
||||
&& prev.selectedGroup === next.selectedGroup
|
||||
&& prev.expandedGroups === next.expandedGroups
|
||||
&& prev.editingNoteId === next.editingNoteId
|
||||
&& prev.editingGroupPath === next.editingGroupPath
|
||||
&& prev.query === next.query
|
||||
&& prev.locale === next.locale
|
||||
&& prev.hosts === next.hosts
|
||||
&& prev.draggingNoteId === next.draggingNoteId
|
||||
&& prev.draggingGroupPath === next.draggingGroupPath
|
||||
&& prev.creatingGroupParent === next.creatingGroupParent
|
||||
&& prev.isSidebarMode === next.isSidebarMode;
|
||||
}
|
||||
|
||||
const NotesTreePane = memo(function NotesTreePane({ renderRows }: NotesTreePaneProps) {
|
||||
return <>{renderRows()}</>;
|
||||
}, notesTreePanePropsEqual);
|
||||
NotesTreePane.displayName = "NotesTreePane";
|
||||
|
||||
export const getNotesGroupDropAction = (
|
||||
sourceGroup: string | null,
|
||||
targetGroup: string,
|
||||
@@ -381,7 +432,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
openNoteRequestId = null,
|
||||
onOpenNoteIdHandled,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { t, locale } = useI18n();
|
||||
const { openExternal } = useApplicationBackend();
|
||||
const isSidebarMode = displayMode === "sidebar";
|
||||
const initialOpenNoteId = openNoteId && notes.some((note) => note.id === openNoteId)
|
||||
@@ -561,10 +612,12 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
flushNoteDraftRef.current();
|
||||
}, []);
|
||||
|
||||
// Hide edge only. The ref is the latest flush, so this does not subscribe
|
||||
// the tree render to draft-writer identity, and it does not remount the editor.
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) return;
|
||||
flushNoteDraft();
|
||||
}, [flushNoteDraft, isActive]);
|
||||
flushNoteDraftRef.current();
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return;
|
||||
@@ -1486,11 +1539,28 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const visibleRootNotes = noteTree.rootNotes.filter(noteMatches);
|
||||
const visibleTree = noteTree.children
|
||||
.map((child) => renderFolderRow(child, 0))
|
||||
.filter(Boolean);
|
||||
const treeIsEmpty = visibleRootNotes.length === 0 && visibleTree.length === 0;
|
||||
// Built only when NotesTreePane's compared props change. isActive / the
|
||||
// hidden class stay on the parent, so a side-panel hide does not reconcile rows.
|
||||
const renderTreeRows = () => {
|
||||
const visibleRootNotes = noteTree.rootNotes.filter(noteMatches);
|
||||
const visibleTree = noteTree.children
|
||||
.map((child) => renderFolderRow(child, 0))
|
||||
.filter(Boolean);
|
||||
const treeIsEmpty = visibleRootNotes.length === 0 && visibleTree.length === 0;
|
||||
return (
|
||||
<>
|
||||
{renderCreateGroupRow(null, 0)}
|
||||
{visibleTree}
|
||||
{visibleRootNotes.map((note) => renderNoteRow(note, 0))}
|
||||
{treeIsEmpty && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-4 text-center text-sm text-muted-foreground">
|
||||
<Search size={20} className="mx-auto mb-2 opacity-60" />
|
||||
{query.trim() ? t("notes.search.noResults") : t("notes.empty.group")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
const hasSearch = query.trim().length > 0;
|
||||
const canExpandCollapse = allGroupPaths.length > 0 && !hasSearch;
|
||||
const shouldShowNotesTree = isSidebarMode || sortedNotes.length > 0;
|
||||
@@ -1750,15 +1820,23 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
}}
|
||||
onDrop={(event) => handleGroupDrop(null, event)}
|
||||
>
|
||||
{renderCreateGroupRow(null, 0)}
|
||||
{visibleTree}
|
||||
{visibleRootNotes.map((note) => renderNoteRow(note, 0))}
|
||||
{treeIsEmpty && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 p-4 text-center text-sm text-muted-foreground">
|
||||
<Search size={20} className="mx-auto mb-2 opacity-60" />
|
||||
{query.trim() ? t("notes.search.noResults") : t("notes.empty.group")}
|
||||
</div>
|
||||
)}
|
||||
<NotesTreePane
|
||||
notes={sortedNotes}
|
||||
noteGroups={groups}
|
||||
selectedNoteId={selectedNoteId}
|
||||
selectedGroup={selectedGroup}
|
||||
expandedGroups={expandedGroups}
|
||||
editingNoteId={editingNoteId}
|
||||
editingGroupPath={editingGroupPath}
|
||||
query={query}
|
||||
locale={locale}
|
||||
hosts={hosts}
|
||||
draggingNoteId={draggingNoteId}
|
||||
draggingGroupPath={draggingGroupPath}
|
||||
creatingGroupParent={creatingGroupParent}
|
||||
isSidebarMode={isSidebarMode}
|
||||
renderRows={renderTreeRows}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -2048,6 +2126,7 @@ export const NotesManager: React.FC<NotesManagerProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kept mounted while the panel is hidden. Do not key this on isActive. */}
|
||||
{isSidebarMode && overlayNoteView && (
|
||||
<div className="absolute inset-0 z-30 flex min-h-0 flex-col bg-background text-foreground">
|
||||
<div className={cn(
|
||||
|
||||
@@ -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,45 @@
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { usePluginHostActions } from '../../application/state/usePluginHostActions';
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from '../../domain/groupConfig';
|
||||
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
|
||||
import { SelectHostPanelContent } from '../SelectHostPanelContent';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { PluginHostDraftPreview } from './PluginHostDraftPreview';
|
||||
|
||||
export function PluginHostActionHost() {
|
||||
const { t } = useI18n();
|
||||
const state = usePluginHostActions();
|
||||
const entry = state.review;
|
||||
if (!entry) return null;
|
||||
const request = entry.prompt.request;
|
||||
const selection = request.kind === 'vault.selectHosts';
|
||||
const connection = request.kind === 'vault.requestConnect';
|
||||
const effective = entry.host ? applyGroupDefaults(entry.host, entry.host.group ? resolveGroupDefaults(entry.host.group, entry.groups) : {}) : undefined;
|
||||
const protocol = effective ? resolveEffectiveTerminalProtocol(effective) : undefined;
|
||||
return <Dialog open onOpenChange={(open) => { if (!open) void state.respond('cancel'); }}>
|
||||
<DialogContent className={selection ? 'max-w-md h-[min(85vh,640px)] flex flex-col p-0 gap-0 overflow-hidden' : 'sm:max-w-[560px] max-h-[85vh] overflow-y-auto'} hideCloseButton>
|
||||
<DialogHeader className={selection ? 'px-4 pt-4 pb-2 shrink-0 border-b border-border/60' : undefined}>
|
||||
<DialogTitle>{t(selection ? 'plugins.hostActions.select' : connection ? 'plugins.hostActions.connect' : entry.host ? 'plugins.hostActions.edit' : 'plugins.hostActions.create')}</DialogTitle>
|
||||
<DialogDescription className="break-all">{entry.prompt.pluginId} · {entry.prompt.pluginVersion}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{selection ? <>
|
||||
<p className="px-4 pt-3 text-xs text-muted-foreground">{t('plugins.hostActions.selectionNote')}</p>
|
||||
<p className="px-4 pt-2 text-sm font-medium break-words">{request.title}</p>
|
||||
{state.selectedIds.length > 50 && <p role="alert" className="px-4 text-sm text-destructive">{t('plugins.hostActions.selectionLimit')}</p>}
|
||||
<SelectHostPanelContent hosts={entry.candidates} customGroups={entry.customGroups} selectedHostIds={state.selectedIds} multiSelect className="flex-1 min-h-0" confirmDisabled={state.busy || state.selectedIds.length > 50} onSelect={(host) => state.setSelectedIds((ids) => ids.includes(host.id) ? ids.filter((id) => id !== host.id) : [...ids, host.id])} onSelectionChange={state.setSelectedIds} onConfirm={() => { if (!state.busy && state.selectedIds.length <= 50) void state.respond('submit'); }} />
|
||||
</> : connection && effective ? <>
|
||||
<p className="break-all text-sm font-medium">{effective.label}</p>
|
||||
<p className="break-all text-sm">{protocol !== 'local' && effective.username ? `${effective.username}@` : ''}{effective.hostname}{protocol !== 'local' && effective.port ? `:${effective.port}` : ''} ({protocol})</p>
|
||||
{protocol === 'local' && <p className="text-sm font-medium" role="note">{t('plugins.hostActions.localExecution')}</p>}
|
||||
<p className="text-sm" role="note">{t(entry.scriptsRun ? 'plugins.hostActions.scriptsRun' : 'plugins.hostActions.noScripts')}</p>
|
||||
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.connectionNote')}</p>
|
||||
</> : request.kind === 'vault.requestHostDraft' ? <PluginHostDraftPreview key={entry.prompt.requestId} before={entry.host} draft={request.draft} managedSources={entry.managedSources} busy={state.busy} onConfirm={(draft) => void state.respond('submit', draft)} /> : null}
|
||||
{state.error && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.failed')}</p>}
|
||||
<DialogFooter className={selection ? 'p-3 border-t' : undefined}>
|
||||
<Button variant="outline" disabled={state.busy} onClick={() => void state.respond('cancel')}>{t('common.cancel')}</Button>
|
||||
{connection && <Button disabled={state.busy} onClick={() => void state.respond('submit')}>{t('common.connect')}</Button>}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { applyPluginHostEdit, normalizePluginHostDraft, pluginHostDraftTouchesManagedSource, pluginHostFieldDiff, PLUGIN_HOST_CREATE_FIELDS, PLUGIN_HOST_EDIT_FIELDS, type PluginHostDraftFields } from '../../domain/pluginHostActions';
|
||||
import type { Host, ManagedSource } from '../../domain/models';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
|
||||
/** Host-owned editable draft and exact normalized field diff. The containing
|
||||
* dialog owns confirmation, expiry and cancellation; this body has no IPC. */
|
||||
export function PluginHostDraftPreview({ before, draft, busy, onConfirm, managedSources = [] }: {
|
||||
before?: Host;
|
||||
draft: PluginHostDraftFields;
|
||||
busy: boolean;
|
||||
onConfirm: (draft: PluginHostDraftFields) => void;
|
||||
managedSources?: ManagedSource[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [fields, setFields] = useState<Record<string, string>>(() => ({
|
||||
label: draft.label ?? before?.label ?? '',
|
||||
hostname: draft.hostname ?? '', username: draft.username ?? '',
|
||||
port: String(draft.port ?? 22), group: draft.group ?? before?.group ?? '',
|
||||
tags: (draft.tags ?? before?.tags ?? []).join(', '),
|
||||
}));
|
||||
const preview = useMemo(() => {
|
||||
const value = {
|
||||
label: fields.label, group: fields.group,
|
||||
tags: fields.tags.split(',').map((tag) => tag.trim()).filter(Boolean),
|
||||
...(!before ? { hostname: fields.hostname, username: fields.username, port: Number(fields.port) } : {}),
|
||||
};
|
||||
try {
|
||||
const after = before ? applyPluginHostEdit(before, value) : normalizePluginHostDraft(value, 'preview');
|
||||
return { value: {
|
||||
label: after.label, group: after.group ?? '', tags: after.tags,
|
||||
...(!before ? { hostname: after.hostname, username: after.username, port: after.port } : {}),
|
||||
}, diff: pluginHostFieldDiff(before, after), managed: pluginHostDraftTouchesManagedSource(after, managedSources) };
|
||||
} catch { return null; }
|
||||
}, [before, fields, managedSources]);
|
||||
return <div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{(before ? PLUGIN_HOST_EDIT_FIELDS : PLUGIN_HOST_CREATE_FIELDS).map((field) => <label key={field} className="space-y-1 text-sm">
|
||||
<span>{t(`hostForm.field.${field}`)}</span>
|
||||
<Input value={fields[field]} type={field === 'port' ? 'number' : 'text'} min={field === 'port' ? 1 : undefined} max={field === 'port' ? 65535 : undefined} maxLength={field === 'tags' ? 32_896 : 1024} autoComplete="off" disabled={busy} onChange={(event) => setFields((previous) => ({ ...previous, [field]: event.target.value }))} />
|
||||
</label>)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('plugins.hostActions.defaultsWarning')}</p>
|
||||
{preview?.managed && <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.managedUnsupported')}</p>}
|
||||
{preview ? <div className="max-h-48 overflow-auto rounded-md border p-3">
|
||||
<p className="mb-2 text-sm font-medium">{t('plugins.hostActions.fieldDiff')}</p>
|
||||
<dl className="space-y-2 text-xs">
|
||||
{preview.diff.map((change) => <div key={change.field} className="break-all">
|
||||
<dt className="font-medium">{t(`hostForm.field.${change.field}`)}</dt>
|
||||
<dd>{JSON.stringify(change.before)} → {JSON.stringify(change.after)}</dd>
|
||||
</div>)}
|
||||
</dl>
|
||||
</div> : <p role="alert" className="text-sm text-destructive">{t('plugins.hostActions.invalidDraft')}</p>}
|
||||
<Button className="w-full" disabled={busy || !preview || preview.managed || preview.diff.length === 0} onClick={() => { if (preview && !preview.managed) onConfirm(preview.value); }}>{t('hostForm.saveHost')}</Button>
|
||||
</div>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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, /<script>/);
|
||||
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\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, /<script>/);
|
||||
});
|
||||
@@ -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; }
|
||||
});
|
||||
@@ -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")}
|
||||
|
||||
@@ -8,3 +8,40 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
|
||||
assert.match(source, /<Toggle\b/);
|
||||
assert.match(source, /Toggle,\s*\n\s*Select,/);
|
||||
});
|
||||
|
||||
function functionBody(functionName: string): string {
|
||||
const start = source.indexOf(`const ${functionName} = useCallback`);
|
||||
assert.notEqual(start, -1, `${functionName} should exist`);
|
||||
const next = source.indexOf("\n const ", start + 1);
|
||||
return source.slice(start, next === -1 ? source.length : next);
|
||||
}
|
||||
|
||||
test("removing an SFTP file association confirms with the in-app dialog", () => {
|
||||
assert.match(source, /import \{ ConfirmDialog \} from "\.\.\/\.\.\/ui\/confirm-dialog";/);
|
||||
assert.doesNotMatch(source, /window\.confirm|globalThis\.confirm|\bconfirm\(/);
|
||||
assert.match(
|
||||
source,
|
||||
/const \[removeConfirmExt, setRemoveConfirmExt\] = useState<string \| null>\(null\);/,
|
||||
);
|
||||
|
||||
const handleRemove = functionBody("handleRemove");
|
||||
assert.match(handleRemove, /setRemoveConfirmExt\(extension\)/);
|
||||
assert.doesNotMatch(handleRemove, /removeAssociation\(/);
|
||||
|
||||
const handleConfirm = functionBody("handleConfirmRemove");
|
||||
assert.match(handleConfirm, /if \(!removeConfirmExt\) return;/);
|
||||
assert.match(handleConfirm, /removeAssociation\(removeConfirmExt\);\s*setRemoveConfirmExt\(null\);/);
|
||||
|
||||
const dialogStart = source.indexOf("<ConfirmDialog");
|
||||
assert.notEqual(dialogStart, -1, "ConfirmDialog should be rendered");
|
||||
const dialog = source.slice(dialogStart);
|
||||
assert.match(dialog, /open=\{removeConfirmExt !== null\}/);
|
||||
assert.match(dialog, /settings\.sftpFileAssociations\.removeConfirm/);
|
||||
assert.match(dialog, /confirmLabel=\{t\('action\.remove'\)\}/);
|
||||
assert.match(dialog, /\bdestructive\b/);
|
||||
assert.match(dialog, /onConfirm=\{handleConfirmRemove\}/);
|
||||
|
||||
const onOpenChange = dialog.slice(dialog.indexOf("onOpenChange"), dialog.indexOf("onConfirm"));
|
||||
assert.match(onOpenChange, /if \(!open\) setRemoveConfirmExt\(null\);/);
|
||||
assert.doesNotMatch(onOpenChange, /removeAssociation\(/);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { useSettingsState } from "../../../application/state/useSettingsState";
|
||||
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import { Button } from "../../ui/button";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
|
||||
import {
|
||||
SectionHeader,
|
||||
@@ -52,6 +53,7 @@ export default function SettingsFileAssociationsTab() {
|
||||
const defaultOpener = getDefaultOpener();
|
||||
const [editingExtension, setEditingExtension] = useState<string | null>(null);
|
||||
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
|
||||
const [removeConfirmExt, setRemoveConfirmExt] = useState<string | null>(null);
|
||||
|
||||
const defaultOpenerValue = useMemo(() => {
|
||||
if (!defaultOpener) return 'ask';
|
||||
@@ -60,10 +62,14 @@ export default function SettingsFileAssociationsTab() {
|
||||
}, [defaultOpener]);
|
||||
|
||||
const handleRemove = useCallback((extension: string) => {
|
||||
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
|
||||
removeAssociation(extension);
|
||||
}
|
||||
}, [removeAssociation, t]);
|
||||
setRemoveConfirmExt(extension);
|
||||
}, []);
|
||||
|
||||
const handleConfirmRemove = useCallback(() => {
|
||||
if (!removeConfirmExt) return;
|
||||
removeAssociation(removeConfirmExt);
|
||||
setRemoveConfirmExt(null);
|
||||
}, [removeAssociation, removeConfirmExt]);
|
||||
|
||||
const handleSelectDefaultSystemApp = useCallback(async () => {
|
||||
setIsSelectingDefaultApp(true);
|
||||
@@ -372,6 +378,16 @@ export default function SettingsFileAssociationsTab() {
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={removeConfirmExt !== null}
|
||||
title={removeConfirmExt ? t('settings.sftpFileAssociations.removeConfirm', { ext: removeConfirmExt === 'file' ? t('sftp.opener.noExtension') : removeConfirmExt }) : ''}
|
||||
confirmLabel={t('action.remove')}
|
||||
destructive
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setRemoveConfirmExt(null);
|
||||
}}
|
||||
onConfirm={handleConfirmRemove}
|
||||
/>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -77,13 +77,13 @@ export const ClaudeCodeCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t('ai.claude.path')}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -102,7 +102,7 @@ export const ClaudeCodeCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.claude.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
|
||||
@@ -117,13 +117,13 @@ export const CodebuddyCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codebuddy.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t('ai.codebuddy.path')}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,7 +142,7 @@ export const CodebuddyCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codebuddy.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
|
||||
@@ -108,13 +108,13 @@ export const CodexConnectionCard: React.FC<{
|
||||
</div>
|
||||
|
||||
{found && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
|
||||
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<span className="text-muted-foreground shrink-0">{t('ai.codex.path')}</span>
|
||||
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
|
||||
{pathInfo.version && (
|
||||
<>
|
||||
<span className="text-muted-foreground">|</span>
|
||||
<span className="text-muted-foreground">{pathInfo.version}</span>
|
||||
<span className="text-muted-foreground shrink-0">|</span>
|
||||
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -133,7 +133,7 @@ export const CodexConnectionCard: React.FC<{
|
||||
value={customPath}
|
||||
onChange={(e) => onCustomPathChange(e.target.value)}
|
||||
placeholder={t('ai.codex.customPathPlaceholder')}
|
||||
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
|
||||
<RefreshCw size={14} className="mr-1.5" />
|
||||
|
||||
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user