mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bf7c25fc | ||
|
|
d350b02f78 | ||
|
|
d8f43a7138 | ||
|
|
34c3431483 | ||
|
|
faeaa238e9 | ||
|
|
5be4731c4d | ||
|
|
ebc1ac7598 | ||
|
|
570b211e71 | ||
|
|
e61992a911 | ||
|
|
0e79f2193d | ||
|
|
c27d904f23 | ||
|
|
cd7f182401 | ||
|
|
260b06540f | ||
|
|
57089a3abf | ||
|
|
3c67392455 | ||
|
|
a01563931b | ||
|
|
0ccafbabf7 | ||
|
|
cd0fa39f49 | ||
|
|
c78121888b | ||
|
|
f1be47adc4 |
No files matched your search
@@ -130,6 +130,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
- **分割ペイン** — 水平・垂直分割でマルチタスク
|
||||
- **セッション管理** — 複数の接続を並行して扱う
|
||||
- **インライン画像** — リモートプログラムが出力する Kitty グラフィックス、SIXEL、iTerm インライン画像を描画
|
||||
- **アーカイブショートカット** — Linux セッションでは、出力された `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz`、`.zip` の名前を Ctrl/Cmd+クリックすると、アーカイブの絶対パスを含む展開コマンドがカーソル位置に挿入されます(入力済みの文字は残ります)。Enter で実行します。カレントディレクトリからの相対名にはディレクトリ追跡(OSC 7、ツールバーの Configure directory tracking)が必要です。絶対パスは常に対象です。Windows、WSL、macOS のセッションは対象外です。
|
||||
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
|
||||
@@ -133,6 +139,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
|
||||
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
|
||||
- **Archive shortcuts** — on Linux sessions, Ctrl/Cmd+click a printed `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, `.tar.xz` or `.zip` name to fill an extraction command with the archive's absolute path into the prompt (kept next to any typed text); press Enter to run it. Names relative to the current directory need directory tracking (OSC 7, see the toolbar's Configure directory tracking); absolute paths always work. Windows, WSL, and macOS sessions are excluded.
|
||||
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
- **分屏** —— 水平/垂直分割,多任务并行
|
||||
- **多会话管理** —— 多连接并排处理
|
||||
- **内联图片** —— 直接渲染远端程序输出的 Kitty 图形、SIXEL 与 iTerm 内联图片
|
||||
- **压缩包快捷填充** —— 在 Linux 会话里,按住 Ctrl/Cmd 点击输出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 文件名,会在光标处填入带绝对路径的解压命令(保留已经输入的文字);按 Enter 才会执行。相对当前目录的文件名需要目录追踪(OSC 7,见工具栏「配置目录追踪」);绝对路径始终可点。Windows、WSL 与 macOS 会话不在范围内。
|
||||
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
- **分割窗格** —— 水平與垂直分割,方便多工
|
||||
- **工作階段管理** —— 多條連線並排處理
|
||||
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
|
||||
- **壓縮檔捷徑** —— 在 Linux 工作階段的終端機裡,按住 Ctrl/Cmd 點選輸出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 檔名,會在游標處填入含絕對路徑的解壓縮命令(保留已輸入的文字);按 Enter 才會執行。相對於目前目錄的檔名需要目錄追蹤(OSC 7,見工具列「設定目錄追蹤」);絕對路徑一律可點。Windows、WSL 與 macOS 工作階段不支援。
|
||||
|
||||
### 📁 SFTP + 內建編輯器
|
||||
- **檔案工作流** —— 拖放上傳/下載
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("handleConnectToHost reads temporary connection settings at call time", () => {
|
||||
assert.match(source, /const temporaryConnectionSettingsRef = useRef\(terminalSettings\)/);
|
||||
assert.match(source, /temporaryConnectionSettingsRef\.current = terminalSettings/);
|
||||
|
||||
const handlerStart = source.indexOf("const handleConnectToHost = useCallback");
|
||||
const handlerEnd = source.indexOf("const handleConnectToHostWithTabName");
|
||||
assert.ok(handlerStart >= 0 && handlerEnd > handlerStart);
|
||||
const handler = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
assert.match(handler, /temporaryConnectionSettingsRef\.current/);
|
||||
assert.match(handler, /temporaryConnectionPrepareOptions\(liveTerminalSettings\)/);
|
||||
assert.match(handler, /terminalSettings: liveTerminalSettings/);
|
||||
assert.doesNotMatch(handler, /terminalSettings\.temporaryConnections/);
|
||||
assert.doesNotMatch(handler, /\[addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings\]/);
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSsh
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
|
||||
import { prepareConnectionHost, temporaryConnectionPrepareOptions } from '../../domain/legacyAlgorithms';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import {
|
||||
@@ -1448,13 +1449,24 @@ export function AppSideEffects() {
|
||||
name,
|
||||
), [createWorkspaceFromTargets, resolveEffectiveHost]);
|
||||
|
||||
// VaultView memo ignores onConnect identity, so this callback can stay
|
||||
// closed over an older render. Temporary-connection toggles are read here,
|
||||
// at connect time, so turning them on or off applies without a reload.
|
||||
const temporaryConnectionSettingsRef = useRef(terminalSettings);
|
||||
temporaryConnectionSettingsRef.current = terminalSettings;
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
|
||||
if (host.ephemeral) {
|
||||
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
|
||||
const connectionHost = prepareConnectionHost(
|
||||
host,
|
||||
temporaryConnectionPrepareOptions(liveTerminalSettings),
|
||||
);
|
||||
if (connectionHost.ephemeral) {
|
||||
setEphemeralHosts((previous) => {
|
||||
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
|
||||
if (existingIndex < 0) return [...previous, host];
|
||||
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
|
||||
const existingIndex = previous.findIndex((candidate) => candidate.id === connectionHost.id);
|
||||
if (existingIndex < 0) return [...previous, connectionHost];
|
||||
return previous.map((candidate, index) => index === existingIndex ? connectionHost : candidate);
|
||||
});
|
||||
}
|
||||
const effectiveHostResolver = alreadyEffective
|
||||
@@ -1464,16 +1476,16 @@ export function AppSideEffects() {
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host,
|
||||
host: connectionHost,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
terminalSettings: liveTerminalSettings,
|
||||
}), connectionHost, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { openQuickConnectFromInput } from '../quickConnectBridge';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
@@ -376,6 +377,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
|
||||
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
|
||||
|
||||
const pendingKeyboardRequest = keyboardInteractiveQueue[0] || null;
|
||||
const pendingTotpUri = pendingKeyboardRequest?.totpUri;
|
||||
const handleFillTotp = useCallback(async () => {
|
||||
if (!pendingTotpUri) return null;
|
||||
const result = await netcattyBridge.get()?.previewTotp?.({ uri: pendingTotpUri });
|
||||
return result?.ok && result.code ? result.code : null;
|
||||
}, [pendingTotpUri]);
|
||||
|
||||
const isPeerSessionWindow = typeof window !== 'undefined'
|
||||
&& window.location.hash.startsWith('#/session-window');
|
||||
const externalMcpToggle = useExternalMcpToggleState();
|
||||
@@ -930,6 +939,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
showSftpTab={showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onQuickConnect={openQuickConnectFromInput}
|
||||
onEditHost={(host) => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
@@ -1022,6 +1032,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
request={keyboardInteractiveQueue[0] || null}
|
||||
onSubmit={handleKeyboardInteractiveSubmit}
|
||||
onCancel={handleKeyboardInteractiveCancel}
|
||||
canFillTotp={Boolean(pendingTotpUri)}
|
||||
onFillTotp={handleFillTotp}
|
||||
/>
|
||||
{/* Indicator when more 2FA requests are pending */}
|
||||
{keyboardInteractiveQueue.length > 1 && (
|
||||
|
||||
@@ -554,6 +554,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
allowSavePassword: request.allowSavePassword !== false,
|
||||
...(typeof request.totpUri === "string" && request.totpUri && !request.totpUri.startsWith("enc:v1:")
|
||||
? { totpUri: request.totpUri }
|
||||
: {}),
|
||||
}]);
|
||||
});
|
||||
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
||||
|
||||
@@ -289,6 +289,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 did not become ready in time. Confirm `opencode serve` works in a terminal, then retry.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 stopped before the chat could start.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 refused the connection. Retry the chat.',
|
||||
'ai.opencode.v2.unavailable': 'OpenCode 2 could not be reached. Check that it is installed and try again.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 could not finish this turn.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 returned an empty response. Run `opencode` in a terminal to configure authentication and models.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 could not apply the Netcatty conversation context.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 could not attach an MCP tool. The chat will continue without it.',
|
||||
|
||||
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -376,6 +384,54 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
"ai.privacy.title": "AI privacy",
|
||||
"ai.privacy.description": "Filters known sensitive text patterns in Netcatty-managed context and tool results for Catty, integrated agents and external MCP clients. Off by default. It cannot detect every secret. Binary tool responses are blocked while enabled.",
|
||||
"ai.privacy.boundary": "Only Netcatty-managed data and tools are covered. Third-party agents' native tools, direct file access and other MCP servers remain outside this boundary.",
|
||||
"ai.privacy.enable": "Enable text redaction",
|
||||
"ai.privacy.privateIps": "Redact private IP addresses",
|
||||
"ai.privacy.privateIps.description": "Also hide private and local IP addresses in returned text.",
|
||||
"ai.privacy.cidrs": "Additional IP ranges (CIDR)",
|
||||
"ai.privacy.cidrs.description": "One IPv4 or IPv6 CIDR per line. Leave empty to use only built-in rules.",
|
||||
"ai.privacy.patterns": "Custom regular expressions",
|
||||
"ai.privacy.patterns.description": "One RE2-compatible pattern per line, without / delimiters or flags. Unsupported or unsafe patterns are rejected.",
|
||||
"ai.privacy.connectionScope": "Placeholders stay stable within one managed conversation or external MCP connection. Original values are restored only through Netcatty-managed tool calls in that same scope.",
|
||||
"ai.privacy.reconnect": "Apply changes, then start a new managed conversation and reconnect external MCP clients. Existing conversations and connections must not reuse placeholders.",
|
||||
"ai.privacy.apply": "Apply",
|
||||
"ai.privacy.applying": "Applying...",
|
||||
"ai.privacy.applied": "AI privacy settings applied. Start a new managed conversation and reconnect external MCP clients.",
|
||||
"ai.privacy.failed": "AI privacy settings were not applied.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
|
||||
'ai.externalMcp.http.enable': 'Enable HTTP',
|
||||
'ai.externalMcp.http.port': 'Port',
|
||||
'ai.externalMcp.http.portHint': 'Fixed port, default 47391. Client URLs stay the same across restarts. Allowed range: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Listen on another address',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'By default the endpoint binds to 127.0.0.1. Turn this on for WSL2 NAT and set 0.0.0.0 or the vEthernet IP.',
|
||||
'ai.externalMcp.http.listenAddress': 'Listen address',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Client address',
|
||||
'ai.externalMcp.http.clientHost.description': 'Used in copied configs when the listener is 0.0.0.0 or ::. Enter the IP clients can reach, such as the Windows host IP.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Enter the IP clients should connect to. 0.0.0.0 is not a reachable address.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'Client address must be a reachable IP, not 0.0.0.0 or ::.',
|
||||
'ai.externalMcp.http.warning': 'Listening beyond 127.0.0.1 exposes External MCP to other machines that can reach this address. Keep the bearer token private and do not publish this port.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Regenerate',
|
||||
'ai.externalMcp.http.regenerateHint': 'Issues a new bearer token. HTTP clients must use the new token. The stdio discovery file is updated; clients that are already connected over stdio stay connected.',
|
||||
'ai.externalMcp.http.wslHint': 'With WSL mirrored networking, localhost works. Otherwise listen on 0.0.0.0 or the vEthernet IP and set Client address to the Windows host IP.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'Generic JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token env',
|
||||
'ai.externalMcp.http.notListening': 'Not listening',
|
||||
'ai.externalMcp.http.listening': 'Listening',
|
||||
'ai.externalMcp.http.portInUse': 'Port {port} is already in use.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Turn on External MCP before this endpoint will listen.',
|
||||
'ai.externalMcp.http.invalidPort': 'Enter a port from 1024 to 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'Listen address must be an IP, such as 0.0.0.0 or the vEthernet IP.',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP failed to listen.',
|
||||
'ai.externalMcp.http.legacySseNote': 'Legacy HTTP+SSE (2024-11-05) is not offered. Use this Streamable HTTP URL.',
|
||||
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Write permission mode',
|
||||
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs Netcatty write tools without Netcatty approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
|
||||
|
||||
@@ -836,6 +836,10 @@ Highlight the focused split pane:
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Allow legacy algorithms for temporary connections',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH connections with no saved host, including PuTTY-style launches and quick connect, offer the same deprecated algorithms as the per-host option. Saved hosts keep their own setting. Off by default; these algorithms have known weaknesses.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Network device mode for temporary connections',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH connections with no saved host, including external launches and quick connect, start in network device mode. Saved hosts and groups keep their own device type. Off by default.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
@@ -860,8 +864,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
@@ -1176,6 +1180,7 @@ Highlight the focused split pane:
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadKubernetes': 'Failed to load Kubernetes resources',
|
||||
'systemManager.errors.loadPods': 'Failed to load pods',
|
||||
'systemManager.errors.nerdctlUnreachable': "nerdctl can't reach containerd",
|
||||
'systemManager.errors.clusterUnreachable': "Can't reach the cluster for context {{context}}",
|
||||
'systemManager.errors.clusterUnreachableGeneric': "Can't reach the cluster",
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
@@ -132,7 +137,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.docker.unavailable': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
@@ -194,6 +199,36 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
'systemManager.runtime.switch': 'Container runtime',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Context',
|
||||
'systemManager.k8s.namespace': 'Namespace',
|
||||
'systemManager.k8s.empty': 'No pods found',
|
||||
'systemManager.k8s.search': 'Search pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describe',
|
||||
'systemManager.k8s.delete': 'Delete pod',
|
||||
'systemManager.k8s.confirmDelete': 'Delete pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} restarts',
|
||||
'systemManager.k8s.chooseContainer': 'Choose a container',
|
||||
'systemManager.k8s.noContext': 'No Kubernetes context on this host',
|
||||
'systemManager.k8s.status.Running': 'Running',
|
||||
'systemManager.k8s.status.Pending': 'Pending',
|
||||
'systemManager.k8s.status.Completed': 'Completed',
|
||||
'systemManager.k8s.status.Succeeded': 'Succeeded',
|
||||
'systemManager.k8s.status.Failed': 'Failed',
|
||||
'systemManager.k8s.status.Unknown': 'Unknown',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'CrashLoopBackOff',
|
||||
'systemManager.k8s.status.ContainerCreating': 'ContainerCreating',
|
||||
'systemManager.k8s.status.Terminating': 'Terminating',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'ImagePullBackOff',
|
||||
'systemManager.k8s.status.ErrImagePull': 'ErrImagePull',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Evicted',
|
||||
'systemManager.k8s.status.OOMKilled': 'OOMKilled',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
|
||||
@@ -912,11 +912,13 @@ export const enTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.verificationCode': 'Verification code',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.savePassword': 'Save password',
|
||||
'keyboard.interactive.fillTotp': 'Fill verification code',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const enVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.quickConnectTo': 'Quick connect to {target}',
|
||||
'qs.hosts': 'Hosts',
|
||||
'qs.tabs': 'Tabs',
|
||||
'qs.newWorkspace': 'New Workspace',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
'qs.localShells': 'Local Shells',
|
||||
@@ -876,6 +880,35 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Use the selected SSH certificate, with the saved password as a fallback when present.',
|
||||
'hostDetails.auth.mfaFirst': 'Prefer interactive auth',
|
||||
'hostDetails.auth.mfaFirst.desc': 'For servers that require password and MFA through keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Authenticator (TOTP)',
|
||||
'hostDetails.totp.enable': 'Save a TOTP secret for this host',
|
||||
'hostDetails.totp.setup': 'Set up…',
|
||||
'hostDetails.totp.edit': 'Edit',
|
||||
'hostDetails.totp.modalTitle': 'Set up authenticator',
|
||||
'hostDetails.totp.replacePlaceholder': 'Paste a new secret to replace the current one',
|
||||
'hostDetails.totp.warning': 'Anyone who can open this vault gets this code and the password. Use it only on a personal device.',
|
||||
'hostDetails.totp.secret': 'Secret or otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': 'Paste otpauth://totp/... or a Base32 secret',
|
||||
'hostDetails.totp.importQr': 'Import QR image',
|
||||
'hostDetails.totp.scanScreen': 'Scan QR code from screen',
|
||||
'hostDetails.totp.scanning': 'Scanning screens…',
|
||||
'hostDetails.totp.importedAccount': 'Imported {account}',
|
||||
'hostDetails.totp.preview': 'Current code',
|
||||
'hostDetails.totp.countdown': '{seconds}s remaining',
|
||||
'hostDetails.totp.autoSubmit': 'Auto-submit on known TOTP prompts',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Submits once per connection, and only when the prompt exactly matches JumpServer, Google Authenticator PAM, or your pattern.',
|
||||
'hostDetails.totp.promptPattern': 'Custom prompt pattern',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Optional RE2 regular expression',
|
||||
'hostDetails.totp.clear': 'Remove authenticator',
|
||||
'hostDetails.totp.removeTitle': 'Remove authenticator?',
|
||||
'hostDetails.totp.removeMessage': 'This host will no longer store this TOTP secret.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP is not supported. Use a TOTP secret.',
|
||||
'hostDetails.totp.error.migration': 'otpauth-migration QR codes are not supported. Use the service binding QR code for a single account.',
|
||||
'hostDetails.totp.error.invalid': 'Could not read a TOTP secret from that value.',
|
||||
'hostDetails.totp.error.notFound': 'No TOTP QR code was found.',
|
||||
'hostDetails.totp.error.permission': 'Screen recording permission is required. Allow Netcatty to record the screen in System Settings, then try again.',
|
||||
'hostDetails.totp.error.unavailable': 'Screen capture is not available.',
|
||||
'hostDetails.totp.error.pattern': 'The custom prompt pattern is not a valid RE2 regular expression.',
|
||||
'hostDetails.auth.credentialRequired': 'Select a key or certificate before saving this login method.',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
|
||||
@@ -367,6 +367,54 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
"ai.privacy.title": "Privacidad de IA",
|
||||
"ai.privacy.description": "Filtra patrones de texto sensibles conocidos en el contexto y los resultados de herramientas gestionados por Netcatty para Catty, agentes integrados y clientes MCP externos. Desactivado de forma predeterminada. No detecta todos los secretos. Las respuestas binarias de herramientas se bloquean mientras está activo.",
|
||||
"ai.privacy.boundary": "Solo cubre los datos y las herramientas gestionados por Netcatty. Las herramientas nativas de agentes de terceros, su acceso directo a archivos y otros servidores MCP quedan fuera de esta protección.",
|
||||
"ai.privacy.enable": "Activar ocultación de texto",
|
||||
"ai.privacy.privateIps": "Ocultar direcciones IP privadas",
|
||||
"ai.privacy.privateIps.description": "Oculta también las direcciones IP privadas y locales en el texto devuelto.",
|
||||
"ai.privacy.cidrs": "Rangos IP adicionales (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Un CIDR IPv4 o IPv6 por línea. Déjalo vacío para usar solo las reglas integradas.",
|
||||
"ai.privacy.patterns": "Expresiones regulares personalizadas",
|
||||
"ai.privacy.patterns.description": "Un patrón compatible con RE2 por línea, sin delimitadores / ni indicadores. Los patrones incompatibles o inseguros se rechazan.",
|
||||
"ai.privacy.connectionScope": "Los marcadores son estables dentro de una conversación gestionada o una conexión MCP externa. Los valores originales solo se restauran mediante llamadas a herramientas gestionadas por Netcatty dentro del mismo ámbito.",
|
||||
"ai.privacy.reconnect": "Aplica los cambios, inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos. Las conversaciones y conexiones existentes no deben reutilizar marcadores.",
|
||||
"ai.privacy.apply": "Aplicar",
|
||||
"ai.privacy.applying": "Aplicando...",
|
||||
"ai.privacy.applied": "Configuración de privacidad de IA aplicada. Inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos.",
|
||||
"ai.privacy.failed": "No se aplicó la configuración de privacidad de IA.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
|
||||
'ai.externalMcp.http.enable': 'Activar HTTP',
|
||||
'ai.externalMcp.http.port': 'Puerto',
|
||||
'ai.externalMcp.http.portHint': 'Puerto fijo, predeterminado 47391. Las URL de los clientes se mantienen al reiniciar. Rango permitido: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Escuchar en otra dirección',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'De forma predeterminada el extremo se enlaza a 127.0.0.1. Actívalo para NAT de WSL2 y usa 0.0.0.0 o la IP de vEthernet.',
|
||||
'ai.externalMcp.http.listenAddress': 'Dirección de escucha',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Dirección del cliente',
|
||||
'ai.externalMcp.http.clientHost.description': 'Se usa en las configuraciones copiadas cuando la escucha es 0.0.0.0 o ::. Introduce una IP alcanzable, como la IP del host de Windows.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Introduce la IP a la que deben conectarse los clientes. 0.0.0.0 no es una dirección alcanzable.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'La dirección del cliente debe ser una IP alcanzable, no 0.0.0.0 ni ::.',
|
||||
'ai.externalMcp.http.warning': 'Escuchar fuera de 127.0.0.1 expone el MCP externo a otros equipos que puedan alcanzar esta dirección. Mantén el bearer token en privado y no publiques este puerto.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Regenerar',
|
||||
'ai.externalMcp.http.regenerateHint': 'Emite un bearer token nuevo. Los clientes HTTP deben usar el token nuevo. El archivo de descubrimiento stdio se actualiza; los clientes stdio ya conectados siguen conectados.',
|
||||
'ai.externalMcp.http.wslHint': 'Con la red reflejada de WSL, localhost funciona. Si no, escucha en 0.0.0.0 o la IP de vEthernet y pon la IP del host de Windows en Dirección del cliente.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'JSON genérico',
|
||||
'ai.externalMcp.http.envLine': 'Variable de entorno del token de Codex',
|
||||
'ai.externalMcp.http.notListening': 'Sin escucha',
|
||||
'ai.externalMcp.http.listening': 'Escuchando',
|
||||
'ai.externalMcp.http.portInUse': 'El puerto {port} ya está en uso.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Activa el MCP externo para que este extremo empiece a escuchar.',
|
||||
'ai.externalMcp.http.invalidPort': 'Introduce un puerto entre 1024 y 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'La dirección de escucha debe ser una IP, como 0.0.0.0 o la IP de vEthernet.',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP no pudo escuchar.',
|
||||
'ai.externalMcp.http.legacySseNote': 'No se ofrece el HTTP+SSE heredado (2024-11-05). Usa esta URL de Streamable HTTP.',
|
||||
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
|
||||
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
|
||||
|
||||
@@ -751,6 +751,10 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.section.connection': 'Conexión',
|
||||
'settings.terminal.connection.verifyHostKeys': 'Verificar las claves de host SSH',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Pregunta antes de conectarse a una clave de host SSH nueva o modificada. Desactívalo solo en redes privadas de confianza.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Permitir algoritmos heredados en conexiones temporales',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios al estilo PuTTY y la conexión rápida, ofrecen los mismos algoritmos obsoletos que la opción por host. Los hosts guardados conservan su propio ajuste. Desactivado de forma predeterminada; estos algoritmos tienen debilidades conocidas.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Modo de dispositivo de red para conexiones temporales',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios externos y la conexión rápida, se inician en modo de dispositivo de red. Los hosts y grupos guardados conservan su propio tipo de dispositivo. Desactivado de forma predeterminada.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Reconectar sesiones SSH automáticamente',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'Cuando está activado, las sesiones SSH establecidas que se caen inesperadamente intentan reconectarse cada 5 segundos hasta que la pestaña se cierra o la conexión se establece.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Intervalo de keepalive',
|
||||
@@ -775,8 +779,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de procesos en el panel lateral del administrador del sistema.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Actualización de sesiones tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de sesiones tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de contenedores Docker.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Actualización de estadísticas Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Con qué frecuencia actualizar las estadísticas de CPU/memoria/red de los contenedores Docker.',
|
||||
'settings.terminal.serverStats.show': 'Mostrar estadísticas del servidor',
|
||||
@@ -1135,6 +1139,7 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadKubernetes': 'No se pudieron cargar los recursos de Kubernetes',
|
||||
'systemManager.errors.loadPods': 'No se pudieron cargar los pods',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
@@ -132,7 +134,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'No se detectó ningún runtime de contenedores (Docker, Podman, nerdctl o kubectl) en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
@@ -194,6 +196,22 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
'systemManager.runtime.switch': 'Runtime de contenedores',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Contexto',
|
||||
'systemManager.k8s.namespace': 'Espacio de nombres',
|
||||
'systemManager.k8s.empty': 'No se encontraron pods',
|
||||
'systemManager.k8s.search': 'Buscar pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describir',
|
||||
'systemManager.k8s.delete': 'Eliminar pod',
|
||||
'systemManager.k8s.confirmDelete': '¿Eliminar el pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -874,11 +874,13 @@ export const esTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
|
||||
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
|
||||
'keyboard.interactive.response': 'Respuesta',
|
||||
'keyboard.interactive.verificationCode': 'Código de verificación',
|
||||
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
|
||||
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
|
||||
'keyboard.interactive.submit': 'Enviar',
|
||||
'keyboard.interactive.verifying': 'Verificando...',
|
||||
'keyboard.interactive.savePassword': 'Guardar contraseña',
|
||||
'keyboard.interactive.fillTotp': 'Rellenar código de verificación',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Frase de contraseña de la clave SSH',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const esVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Buscar hosts o pestañas',
|
||||
'qs.quickConnectTo': 'Conexión rápida a {target}',
|
||||
'qs.hosts': 'Hosts',
|
||||
'qs.tabs': 'Pestañas',
|
||||
'qs.newWorkspace': 'Nuevo espacio de trabajo',
|
||||
'qs.jumpTo': 'Ir a',
|
||||
'qs.localTerminal': 'Terminal local',
|
||||
'qs.localShells': 'Shells locales',
|
||||
@@ -866,6 +870,35 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
|
||||
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Autenticador (TOTP)',
|
||||
'hostDetails.totp.enable': 'Guardar un secreto TOTP para este host',
|
||||
'hostDetails.totp.setup': 'Configurar…',
|
||||
'hostDetails.totp.edit': 'Editar',
|
||||
'hostDetails.totp.modalTitle': 'Configurar autenticador',
|
||||
'hostDetails.totp.replacePlaceholder': 'Pega un secreto nuevo para reemplazar el actual',
|
||||
'hostDetails.totp.warning': 'Quien abra esta caja fuerte obtiene este código y la contraseña. Úsalo solo en un dispositivo personal.',
|
||||
'hostDetails.totp.secret': 'Secreto o URI otpauth',
|
||||
'hostDetails.totp.secretPlaceholder': 'Pega otpauth://totp/... o un secreto Base32',
|
||||
'hostDetails.totp.importQr': 'Importar imagen QR',
|
||||
'hostDetails.totp.scanScreen': 'Escanear código QR de la pantalla',
|
||||
'hostDetails.totp.scanning': 'Escaneando pantallas…',
|
||||
'hostDetails.totp.importedAccount': 'Importado: {account}',
|
||||
'hostDetails.totp.preview': 'Código actual',
|
||||
'hostDetails.totp.countdown': 'Quedan {seconds} s',
|
||||
'hostDetails.totp.autoSubmit': 'Enviar automáticamente en avisos TOTP conocidos',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Se envía una sola vez por conexión y solo si el aviso coincide exactamente con JumpServer, Google Authenticator PAM o tu patrón.',
|
||||
'hostDetails.totp.promptPattern': 'Patrón de aviso personalizado',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Expresión regular RE2 opcional',
|
||||
'hostDetails.totp.clear': 'Quitar autenticador',
|
||||
'hostDetails.totp.removeTitle': '¿Quitar el autenticador?',
|
||||
'hostDetails.totp.removeMessage': 'Este host ya no guardará este secreto TOTP.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP no es compatible. Usa un secreto TOTP.',
|
||||
'hostDetails.totp.error.migration': 'Los códigos otpauth-migration no son compatibles. Usa el código QR de vinculación de una sola cuenta.',
|
||||
'hostDetails.totp.error.invalid': 'No se pudo leer un secreto TOTP de ese valor.',
|
||||
'hostDetails.totp.error.notFound': 'No se encontró un código QR TOTP.',
|
||||
'hostDetails.totp.error.permission': 'Se necesita permiso de grabación de pantalla. Permite que Netcatty grabe la pantalla en Ajustes del sistema e inténtalo de nuevo.',
|
||||
'hostDetails.totp.error.unavailable': 'La captura de pantalla no está disponible.',
|
||||
'hostDetails.totp.error.pattern': 'El patrón personalizado no es una expresión regular RE2 válida.',
|
||||
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
|
||||
'hostDetails.identity.suggestions': 'Identidades',
|
||||
'hostDetails.identity.missing': 'Identidad no encontrada',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
const KEYS = [
|
||||
"ai.externalMcp.http.title",
|
||||
"ai.externalMcp.http.description",
|
||||
"ai.externalMcp.http.enable",
|
||||
"ai.externalMcp.http.port",
|
||||
"ai.externalMcp.http.portHint",
|
||||
"ai.externalMcp.http.allowCustomBind",
|
||||
"ai.externalMcp.http.allowCustomBind.description",
|
||||
"ai.externalMcp.http.listenAddress",
|
||||
"ai.externalMcp.http.listenAddress.placeholder",
|
||||
"ai.externalMcp.http.clientHost",
|
||||
"ai.externalMcp.http.clientHost.description",
|
||||
"ai.externalMcp.http.clientHost.placeholder",
|
||||
"ai.externalMcp.http.clientHostRequired",
|
||||
"ai.externalMcp.http.invalidClientHost",
|
||||
"ai.externalMcp.http.warning",
|
||||
"ai.externalMcp.http.url",
|
||||
"ai.externalMcp.http.token",
|
||||
"ai.externalMcp.http.regenerate",
|
||||
"ai.externalMcp.http.regenerateHint",
|
||||
"ai.externalMcp.http.wslHint",
|
||||
"ai.externalMcp.http.codexSnippet",
|
||||
"ai.externalMcp.http.claudeCommand",
|
||||
"ai.externalMcp.http.genericSnippet",
|
||||
"ai.externalMcp.http.envLine",
|
||||
"ai.externalMcp.http.notListening",
|
||||
"ai.externalMcp.http.listening",
|
||||
"ai.externalMcp.http.portInUse",
|
||||
"ai.externalMcp.http.requiresExternalMcp",
|
||||
"ai.externalMcp.http.invalidPort",
|
||||
"ai.externalMcp.http.invalidAddress",
|
||||
"ai.externalMcp.http.listenFailed",
|
||||
"ai.externalMcp.http.legacySseNote",
|
||||
] as const;
|
||||
|
||||
test("External MCP HTTP strings exist in every locale", () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, "zh-CN": zhCN, "zh-TW": zhTW, ru })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing HTTP MCP labels`);
|
||||
assert.match(messages["ai.externalMcp.http.portInUse"], /\{port\}/);
|
||||
}
|
||||
assert.match(zhTW["ai.externalMcp.http.port"], /連接埠/);
|
||||
assert.match(zhTW["ai.externalMcp.http.listenAddress"], /位址/);
|
||||
assert.match(zhTW["ai.externalMcp.http.clientHost"], /位址/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.clientHost"], /地址/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.port"], /端口/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.listenAddress"], /地址/);
|
||||
assert.match(zhTW["ai.externalMcp.http.wslHint"], /鏡像網路/);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
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';
|
||||
|
||||
const KEYS = [
|
||||
'title', 'description', 'boundary', 'enable', 'privateIps', 'privateIps.description',
|
||||
'cidrs', 'cidrs.description', 'patterns', 'patterns.description',
|
||||
'connectionScope', 'reconnect', 'apply', 'applying', 'applied', 'failed',
|
||||
];
|
||||
|
||||
test('Shared AI privacy settings are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
assert.deepEqual(
|
||||
KEYS.filter((key) => !messages[`ai.privacy.${key}`]),
|
||||
[],
|
||||
`${name} is missing privacy settings labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import en from './en';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const KEYS = [
|
||||
'ai.opencode.v2.startupTimeout',
|
||||
'ai.opencode.v2.exited',
|
||||
'ai.opencode.v2.unauthorized',
|
||||
'ai.opencode.v2.unavailable',
|
||||
'ai.opencode.v2.sessionFailed',
|
||||
'ai.opencode.v2.empty',
|
||||
'ai.opencode.v2.contextSkipped',
|
||||
'ai.opencode.v2.mcpSkipped',
|
||||
];
|
||||
|
||||
test('OpenCode 2 strings exist in English, Simplified Chinese, and Taiwan Chinese', () => {
|
||||
for (const key of KEYS) {
|
||||
assert.equal(typeof en[key], 'string', `en missing ${key}`);
|
||||
assert.equal(typeof zhCN[key], 'string', `zh-CN missing ${key}`);
|
||||
assert.equal(typeof zhTW[key], 'string', `zh-TW missing ${key}`);
|
||||
assert.notEqual(en[key], zhCN[key]);
|
||||
assert.notEqual(en[key], zhTW[key]);
|
||||
assert.notEqual(zhCN[key], zhTW[key]);
|
||||
}
|
||||
});
|
||||
|
||||
test('Taiwan OpenCode 2 wording uses Taiwan terms', () => {
|
||||
const text = KEYS.map((key) => zhTW[key]).join('\n');
|
||||
assert.match(text, /終端機/);
|
||||
assert.match(text, /連線/);
|
||||
assert.match(text, /設定/);
|
||||
assert.match(text, /傳回/);
|
||||
assert.equal(text.includes('终端'), false);
|
||||
assert.equal(text.includes('连接'), false);
|
||||
assert.equal(text.includes('设置'), false);
|
||||
assert.equal(text.includes('返回'), false);
|
||||
});
|
||||
@@ -323,6 +323,54 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
"ai.privacy.title": "Конфиденциальность ИИ",
|
||||
"ai.privacy.description": "Фильтрует известные шаблоны конфиденциального текста в контексте и результатах инструментов, управляемых Netcatty, для Catty, встроенных агентов и внешних MCP-клиентов. По умолчанию отключено. Не обнаруживает все секреты. При включении двоичные ответы инструментов блокируются.",
|
||||
"ai.privacy.boundary": "Защищаются только данные и инструменты, управляемые Netcatty. Собственные инструменты сторонних агентов, их прямой доступ к файлам и другие MCP-серверы не входят в эту область.",
|
||||
"ai.privacy.enable": "Включить скрытие текста",
|
||||
"ai.privacy.privateIps": "Скрывать частные IP-адреса",
|
||||
"ai.privacy.privateIps.description": "Также скрывает частные и локальные IP-адреса в возвращаемом тексте.",
|
||||
"ai.privacy.cidrs": "Дополнительные диапазоны IP (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Один CIDR IPv4 или IPv6 на строку. Оставьте пустым для использования только встроенных правил.",
|
||||
"ai.privacy.patterns": "Пользовательские регулярные выражения",
|
||||
"ai.privacy.patterns.description": "Один RE2-совместимый шаблон на строку, без разделителей / и флагов. Неподдерживаемые или небезопасные шаблоны отклоняются.",
|
||||
"ai.privacy.connectionScope": "Маркеры постоянны в пределах одного управляемого диалога или внешнего MCP-подключения. Исходные значения восстанавливаются только через вызовы инструментов, управляемых Netcatty, в той же области.",
|
||||
"ai.privacy.reconnect": "Примените изменения, начните новый управляемый диалог и переподключите внешние MCP-клиенты. Существующие диалоги и подключения не должны повторно использовать маркеры.",
|
||||
"ai.privacy.apply": "Применить",
|
||||
"ai.privacy.applying": "Применение...",
|
||||
"ai.privacy.applied": "Настройки конфиденциальности ИИ применены. Начните новый управляемый диалог и переподключите внешние MCP-клиенты.",
|
||||
"ai.privacy.failed": "Настройки конфиденциальности ИИ не применены.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
|
||||
'ai.externalMcp.http.enable': 'Включить HTTP',
|
||||
'ai.externalMcp.http.port': 'Порт',
|
||||
'ai.externalMcp.http.portHint': 'Фиксированный порт, по умолчанию 47391. URL клиентов не меняется после перезапуска. Допустимый диапазон: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Слушать другой адрес',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'По умолчанию endpoint привязан к 127.0.0.1. Включите для WSL2 NAT и укажите 0.0.0.0 или IP vEthernet.',
|
||||
'ai.externalMcp.http.listenAddress': 'Адрес прослушивания',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Адрес клиента',
|
||||
'ai.externalMcp.http.clientHost.description': 'Подставляется в копируемые конфиги, если слушатель 0.0.0.0 или ::. Укажите доступный IP, например IP узла Windows.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Укажите IP, к которому должны подключаться клиенты. 0.0.0.0 недоступен для подключения.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'Адрес клиента должен быть доступным IP, а не 0.0.0.0 или ::.',
|
||||
'ai.externalMcp.http.warning': 'Прослушивание не только 127.0.0.1 открывает External MCP другим машинам, которые могут достичь этого адреса. Храните bearer token в секрете и не публикуйте этот порт.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Создать заново',
|
||||
'ai.externalMcp.http.regenerateHint': 'Выпускает новый bearer token. HTTP-клиенты должны использовать новый token. Файл discovery для stdio обновляется; уже подключенные stdio-клиенты остаются подключенными.',
|
||||
'ai.externalMcp.http.wslHint': 'При mirrored networking в WSL достаточно localhost. Иначе слушайте 0.0.0.0 или IP vEthernet и укажите IP узла Windows как адрес клиента.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'Общий JSON',
|
||||
'ai.externalMcp.http.envLine': 'Переменная окружения token для Codex',
|
||||
'ai.externalMcp.http.notListening': 'Не слушает',
|
||||
'ai.externalMcp.http.listening': 'Слушает',
|
||||
'ai.externalMcp.http.portInUse': 'Порт {port} уже занят.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Сначала включите External MCP, чтобы endpoint начал слушать.',
|
||||
'ai.externalMcp.http.invalidPort': 'Введите порт от 1024 до 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'Адрес прослушивания должен быть IP, например 0.0.0.0 или IP vEthernet.',
|
||||
'ai.externalMcp.http.listenFailed': 'Не удалось начать прослушивание HTTP.',
|
||||
'ai.externalMcp.http.legacySseNote': 'Устаревший HTTP+SSE (2024-11-05) не предоставляется. Используйте этот URL Streamable HTTP.',
|
||||
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
|
||||
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
|
||||
|
||||
@@ -791,6 +791,10 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
|
||||
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
|
||||
'settings.terminal.section.connection': 'Подключение',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Устаревшие алгоритмы для временных подключений',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH-подключения без сохранённого хоста, включая запуск в стиле PuTTY и быстрое подключение, предлагают те же устаревшие алгоритмы, что и настройка хоста. Сохранённые хосты используют свою настройку. По умолчанию выключено: у этих алгоритмов есть известные слабости.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Режим сетевого устройства для временных подключений',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH-подключения без сохранённого хоста, включая внешний запуск и быстрое подключение, открываются в режиме сетевого устройства. Сохранённые хосты и группы сохраняют свой тип устройства. По умолчанию выключено.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
|
||||
@@ -813,8 +817,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadKubernetes': 'Не удалось загрузить ресурсы Kubernetes',
|
||||
'systemManager.errors.loadPods': 'Не удалось загрузить поды',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
@@ -132,7 +134,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
@@ -194,6 +196,22 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
'systemManager.runtime.switch': 'Среда выполнения контейнеров',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Контекст',
|
||||
'systemManager.k8s.namespace': 'Пространство имён',
|
||||
'systemManager.k8s.empty': 'Поды не найдены',
|
||||
'systemManager.k8s.search': 'Поиск подов…',
|
||||
'systemManager.k8s.meta': '{{count}} под(ов)',
|
||||
'systemManager.k8s.describe': 'Описание',
|
||||
'systemManager.k8s.delete': 'Удалить под',
|
||||
'systemManager.k8s.confirmDelete': 'Удалить под «{{name}}»?',
|
||||
'systemManager.k8s.restarts': '{{count}} перезапусков',
|
||||
'systemManager.k8s.chooseContainer': 'Выберите контейнер',
|
||||
'systemManager.k8s.noContext': 'На этом хосте нет контекста Kubernetes',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
|
||||
@@ -919,11 +919,13 @@ export const ruTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.response': 'Ответ',
|
||||
'keyboard.interactive.verificationCode': 'Код подтверждения',
|
||||
'keyboard.interactive.enterCode': 'Введите код подтверждения',
|
||||
'keyboard.interactive.enterResponse': 'Введите ответ',
|
||||
'keyboard.interactive.submit': 'Отправить',
|
||||
'keyboard.interactive.verifying': 'Проверка...',
|
||||
'keyboard.interactive.savePassword': 'Сохранить пароль',
|
||||
'keyboard.interactive.fillTotp': 'Подставить код подтверждения',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Парольная фраза SSH-ключа',
|
||||
|
||||
@@ -131,6 +131,7 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} хостов',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
@@ -710,6 +711,10 @@ export const ruVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||
'qs.quickConnectTo': 'Быстрое подключение к {target}',
|
||||
'qs.hosts': 'Хосты',
|
||||
'qs.tabs': 'Вкладки',
|
||||
'qs.newWorkspace': 'Новая рабочая область',
|
||||
'qs.jumpTo': 'Перейти к',
|
||||
'qs.localTerminal': 'Локальный терминал',
|
||||
'qs.localShells': 'Локальные оболочки',
|
||||
@@ -914,6 +919,35 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Использовать выбранный SSH-сертификат и сохранённый пароль как запасной вариант.',
|
||||
'hostDetails.auth.mfaFirst': 'Сначала интерактивная аутентификация',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Для серверов, которым нужны пароль и MFA через keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Аутентификатор (TOTP)',
|
||||
'hostDetails.totp.enable': 'Сохранить секрет TOTP для этого хоста',
|
||||
'hostDetails.totp.setup': 'Настроить…',
|
||||
'hostDetails.totp.edit': 'Изменить',
|
||||
'hostDetails.totp.modalTitle': 'Настройка аутентификатора',
|
||||
'hostDetails.totp.replacePlaceholder': 'Вставьте новый секрет, чтобы заменить текущий',
|
||||
'hostDetails.totp.warning': 'Тот, кто откроет это хранилище, получит этот код и пароль. Используйте только на личном устройстве.',
|
||||
'hostDetails.totp.secret': 'Секрет или URI otpauth',
|
||||
'hostDetails.totp.secretPlaceholder': 'Вставьте otpauth://totp/... или секрет Base32',
|
||||
'hostDetails.totp.importQr': 'Импорт изображения QR',
|
||||
'hostDetails.totp.scanScreen': 'Сканировать QR-код с экрана',
|
||||
'hostDetails.totp.scanning': 'Сканирование экранов…',
|
||||
'hostDetails.totp.importedAccount': 'Импортировано: {account}',
|
||||
'hostDetails.totp.preview': 'Текущий код',
|
||||
'hostDetails.totp.countdown': 'Осталось {seconds} с',
|
||||
'hostDetails.totp.autoSubmit': 'Автоотправка на известных запросах TOTP',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Отправляется один раз за подключение и только если запрос точно совпадает с JumpServer, Google Authenticator PAM или вашим шаблоном.',
|
||||
'hostDetails.totp.promptPattern': 'Свой шаблон запроса',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Необязательное регулярное выражение RE2',
|
||||
'hostDetails.totp.clear': 'Удалить аутентификатор',
|
||||
'hostDetails.totp.removeTitle': 'Удалить аутентификатор?',
|
||||
'hostDetails.totp.removeMessage': 'Этот хост больше не будет хранить этот секрет TOTP.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP не поддерживается. Используйте секрет TOTP.',
|
||||
'hostDetails.totp.error.migration': 'Коды otpauth-migration не поддерживаются. Используйте QR-код привязки одной учетной записи.',
|
||||
'hostDetails.totp.error.invalid': 'Не удалось прочитать секрет TOTP.',
|
||||
'hostDetails.totp.error.notFound': 'QR-код TOTP не найден.',
|
||||
'hostDetails.totp.error.permission': 'Нужно разрешение на запись экрана. Разрешите Netcatty запись экрана в системных настройках и повторите попытку.',
|
||||
'hostDetails.totp.error.unavailable': 'Захват экрана недоступен.',
|
||||
'hostDetails.totp.error.pattern': 'Пользовательский шаблон не является допустимым регулярным выражением RE2.',
|
||||
'hostDetails.auth.credentialRequired': 'Перед сохранением этого способа входа выберите ключ или сертификат.',
|
||||
'hostDetails.identity.suggestions': 'Идентификаторы',
|
||||
'hostDetails.identity.missing': 'Идентификатор не найден',
|
||||
|
||||
@@ -47,6 +47,40 @@ test("localized settings include workspace focus indicator labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include temporary-connection legacy algorithm labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.temporaryLegacyAlgorithms",
|
||||
"settings.terminal.connection.temporaryLegacyAlgorithms.desc",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing temporary legacy algorithm labels`);
|
||||
}
|
||||
|
||||
assert.match(zhCN["settings.terminal.connection.temporaryLegacyAlgorithms"], /临时连接允许旧版算法/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryLegacyAlgorithms"], /臨時連線允許舊版演算法/);
|
||||
});
|
||||
|
||||
test("localized settings include temporary-connection network device mode labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.temporaryNetworkDeviceMode",
|
||||
"settings.terminal.connection.temporaryNetworkDeviceMode.desc",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing temporary network device mode labels`);
|
||||
}
|
||||
|
||||
assert.match(zhCN["settings.terminal.connection.temporaryNetworkDeviceMode"], /临时连接使用网络设备模式/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode"], /臨時連線使用網路裝置模式/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /連線/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /設定|群組|裝置/);
|
||||
});
|
||||
|
||||
test("localized settings include the global default proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.defaultProxy",
|
||||
|
||||
@@ -289,6 +289,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及时就绪。请先在终端确认 `opencode serve` 可以启动,然后重试。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在对话开始前已退出。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒绝了连接。请重试这次对话。',
|
||||
'ai.opencode.v2.unavailable': '无法连接到 OpenCode 2。请确认已安装后再试。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成这一轮对话。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 没有返回内容。请在终端运行 `opencode` 完成登录和模型配置。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 无法应用 Netcatty 的对话上下文。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 无法接入 MCP 工具,对话会继续,但没有该工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -376,6 +384,54 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
"ai.privacy.title": "AI 隐私",
|
||||
"ai.privacy.description": "为 Catty、集成 Agent 和外部 MCP 客户端过滤 Netcatty 管理的上下文及工具结果中已知的敏感文本模式。默认关闭,无法识别所有机密。启用时会阻止二进制工具响应。",
|
||||
"ai.privacy.boundary": "仅覆盖 Netcatty 管理的数据和工具。第三方 Agent 的原生工具、直接读取的文件及其他 MCP 服务器不在保护范围内。",
|
||||
"ai.privacy.enable": "启用文本脱敏",
|
||||
"ai.privacy.privateIps": "隐藏私有 IP 地址",
|
||||
"ai.privacy.privateIps.description": "同时隐藏返回文本中的私有和本地 IP 地址。",
|
||||
"ai.privacy.cidrs": "附加 IP 网段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一个 IPv4 或 IPv6 CIDR。留空则仅使用内置规则。",
|
||||
"ai.privacy.patterns": "自定义正则表达式",
|
||||
"ai.privacy.patterns.description": "每行一个兼容 RE2 的模式,不含 / 分隔符或标志。不支持或不安全的模式会被拒绝。",
|
||||
"ai.privacy.connectionScope": "占位符在同一个受管对话或外部 MCP 连接内保持稳定。只有同一范围内的 Netcatty 受管工具调用才能还原原始值。",
|
||||
"ai.privacy.reconnect": "应用更改后,请新建受管对话并重新连接外部 MCP 客户端。已有对话和连接不得复用占位符。",
|
||||
"ai.privacy.apply": "应用",
|
||||
"ai.privacy.applying": "正在应用...",
|
||||
"ai.privacy.applied": "AI 隐私设置已应用。请新建受管对话并重新连接外部 MCP 客户端。",
|
||||
"ai.privacy.failed": "AI 隐私设置未应用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
|
||||
'ai.externalMcp.http.enable': '启用 HTTP',
|
||||
'ai.externalMcp.http.port': '端口',
|
||||
'ai.externalMcp.http.portHint': '固定端口,默认 47391。重启后客户端地址保持不变。允许范围:1024–65535。',
|
||||
'ai.externalMcp.http.allowCustomBind': '监听其他地址',
|
||||
'ai.externalMcp.http.allowCustomBind.description': '默认只绑定 127.0.0.1。WSL2 NAT 可改为监听 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenAddress': '监听地址',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': '客户端地址',
|
||||
'ai.externalMcp.http.clientHost.description': '监听 0.0.0.0 或 :: 时,复制的配置会使用这个地址。请填写客户端能访问的 IP,例如 Windows 主机 IP。',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': '请填写客户端要连接的 IP。0.0.0.0 不是可连接的地址。',
|
||||
'ai.externalMcp.http.invalidClientHost': '客户端地址必须是可连接的 IP,不能是 0.0.0.0 或 ::。',
|
||||
'ai.externalMcp.http.warning': '监听 127.0.0.1 以外的地址会让能访问这台电脑的其他设备使用对外 MCP。请保管 bearer token,不要把这个端口暴露到公网。',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': '重新生成',
|
||||
'ai.externalMcp.http.regenerateHint': '生成新的 bearer token。HTTP 客户端必须改用新 token。stdio 的 discovery 文件会一起更新;已经通过 stdio 连上的客户端保持连接。',
|
||||
'ai.externalMcp.http.wslHint': 'WSL 若使用镜像网络,localhost 即可连接。否则请监听 0.0.0.0 或 vEthernet IP,并把客户端地址设为 Windows 主机 IP。',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': '通用 JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token 环境变量',
|
||||
'ai.externalMcp.http.notListening': '未监听',
|
||||
'ai.externalMcp.http.listening': '监听中',
|
||||
'ai.externalMcp.http.portInUse': '端口 {port} 已被占用。',
|
||||
'ai.externalMcp.http.requiresExternalMcp': '请先打开对外 MCP,HTTP 端点才会开始监听。',
|
||||
'ai.externalMcp.http.invalidPort': '请输入 1024 到 65535 的端口。',
|
||||
'ai.externalMcp.http.invalidAddress': '监听地址必须是 IP,例如 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP 监听失败。',
|
||||
'ai.externalMcp.http.legacySseNote': '不提供旧版 HTTP+SSE(2024-11-05)。请使用这个 Streamable HTTP 地址。',
|
||||
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '写操作权限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
|
||||
|
||||
@@ -785,6 +785,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
@@ -1139,6 +1140,10 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.quickConnectTo': '快速连接到 {target}',
|
||||
'qs.hosts': '主机',
|
||||
'qs.tabs': '标签页',
|
||||
'qs.newWorkspace': '新建工作区',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
'qs.localShells': '本地 Shell',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadKubernetes': '无法加载 Kubernetes 资源',
|
||||
'systemManager.errors.loadPods': '无法加载 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 无法连接 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '无法连接到上下文 {{context}} 的集群',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '无法连接到集群',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
@@ -132,7 +137,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
@@ -194,6 +199,36 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器运行时',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '上下文',
|
||||
'systemManager.k8s.namespace': '命名空间',
|
||||
'systemManager.k8s.empty': '未找到 Pod',
|
||||
'systemManager.k8s.search': '搜索 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 个 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '删除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '确定删除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重启 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '选择容器',
|
||||
'systemManager.k8s.noContext': '此主机没有 Kubernetes 上下文',
|
||||
'systemManager.k8s.status.Running': '运行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失败',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '崩溃循环',
|
||||
'systemManager.k8s.status.ContainerCreating': '创建中',
|
||||
'systemManager.k8s.status.Terminating': '终止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '镜像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取镜像失败',
|
||||
'systemManager.k8s.status.Error': '错误',
|
||||
'systemManager.k8s.status.Evicted': '已驱逐',
|
||||
'systemManager.k8s.status.OOMKilled': '内存不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
|
||||
@@ -454,6 +454,10 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目录不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '临时连接允许旧版算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '没有对应已保存主机的 SSH 连接(包括外部程序以 PuTTY 参数启动,以及快速连接)会使用与主机「允许旧版算法」相同的已弃用算法。已保存主机仍使用自己的设置。默认关闭,这些算法有已知安全问题。',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': '临时连接使用网络设备模式',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '没有对应已保存主机的 SSH 连接(包括外部程序启动以及快速连接)会以网络设备模式连接。已保存主机和分组仍使用自己的设备类型。默认关闭。',
|
||||
'settings.terminal.connection.defaultProxy': '默认代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
|
||||
'settings.terminal.connection.defaultProxy.none': '无',
|
||||
@@ -483,8 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
@@ -999,11 +1003,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.verificationCode': '验证码',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.savePassword': '保存密码',
|
||||
'keyboard.interactive.fillTotp': '填入验证码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
|
||||
@@ -290,6 +290,35 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': '使用选中的 SSH 证书;如果同时保存了密码,可在失败后尝试密码。',
|
||||
'hostDetails.auth.mfaFirst': '优先交互式认证',
|
||||
'hostDetails.auth.mfaFirst.desc': '适用于必须通过 keyboard-interactive 输入密码和 MFA 的服务器。',
|
||||
'hostDetails.totp.title': '验证器 (TOTP)',
|
||||
'hostDetails.totp.enable': '为这台主机保存 TOTP 密钥',
|
||||
'hostDetails.totp.setup': '设置…',
|
||||
'hostDetails.totp.edit': '编辑',
|
||||
'hostDetails.totp.modalTitle': '设置验证器',
|
||||
'hostDetails.totp.replacePlaceholder': '粘贴新密钥以替换当前密钥',
|
||||
'hostDetails.totp.warning': '能打开这个保险库的人会同时得到验证码和密码。请只在个人设备上使用。',
|
||||
'hostDetails.totp.secret': '密钥或 otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': '粘贴 otpauth://totp/... 或 Base32 密钥',
|
||||
'hostDetails.totp.importQr': '导入二维码图片',
|
||||
'hostDetails.totp.scanScreen': '从屏幕扫描二维码',
|
||||
'hostDetails.totp.scanning': '正在扫描屏幕…',
|
||||
'hostDetails.totp.importedAccount': '已导入 {account}',
|
||||
'hostDetails.totp.preview': '当前验证码',
|
||||
'hostDetails.totp.countdown': '剩余 {seconds} 秒',
|
||||
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自动提交',
|
||||
'hostDetails.totp.autoSubmitDesc': '每次连接只提交一次,且提示必须精确匹配 JumpServer、Google Authenticator PAM,或你填写的正则。',
|
||||
'hostDetails.totp.promptPattern': '自定义提示正则',
|
||||
'hostDetails.totp.promptPatternPlaceholder': '可选的 RE2 正则表达式',
|
||||
'hostDetails.totp.clear': '移除验证器',
|
||||
'hostDetails.totp.removeTitle': '移除验证器?',
|
||||
'hostDetails.totp.removeMessage': '这台主机将不再保存这个 TOTP 密钥。',
|
||||
'hostDetails.totp.error.hotp': '不支持 HOTP。请使用 TOTP 密钥。',
|
||||
'hostDetails.totp.error.migration': '不支持 otpauth-migration 二维码。请使用服务提供的单个账号绑定二维码。',
|
||||
'hostDetails.totp.error.invalid': '无法从该内容读取 TOTP 密钥。',
|
||||
'hostDetails.totp.error.notFound': '没有找到 TOTP 二维码。',
|
||||
'hostDetails.totp.error.permission': '需要屏幕录制权限。请在系统设置中允许 Netcatty 录制屏幕后再试。',
|
||||
'hostDetails.totp.error.unavailable': '无法使用屏幕捕获。',
|
||||
'hostDetails.totp.error.pattern': '自定义提示正则不是有效的 RE2 正则表达式。',
|
||||
'hostDetails.auth.credentialRequired': '保存这种登录方式前,请先选择密钥或证书。',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
|
||||
@@ -280,6 +280,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及時就緒。請先在終端機確認 `opencode serve` 可以啟動,然後重試。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在對話開始前已結束。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒絕了連線。請重試這次對話。',
|
||||
'ai.opencode.v2.unavailable': '無法連線到 OpenCode 2。請確認已安裝後再試。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成這一輪對話。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 沒有傳回內容。請在終端機執行 `opencode` 完成登入和模型設定。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 無法套用 Netcatty 的對話內容。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 無法掛接 MCP 工具,對話會繼續,但沒有該工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -366,6 +374,54 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
"ai.privacy.title": "AI 隱私",
|
||||
"ai.privacy.description": "為 Catty、整合 Agent 和外部 MCP 用戶端過濾 Netcatty 管理的上下文及工具結果中已知的敏感文字模式。預設關閉,無法識別所有機密。啟用時會封鎖二進位工具回應。",
|
||||
"ai.privacy.boundary": "僅涵蓋 Netcatty 管理的資料和工具。第三方 Agent 的原生工具、直接讀取的檔案及其他 MCP 伺服器不在保護範圍內。",
|
||||
"ai.privacy.enable": "啟用文字去識別化",
|
||||
"ai.privacy.privateIps": "隱藏私有 IP 位址",
|
||||
"ai.privacy.privateIps.description": "同時隱藏回傳文字中的私有和本機 IP 位址。",
|
||||
"ai.privacy.cidrs": "額外 IP 網段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一個 IPv4 或 IPv6 CIDR。留空則僅使用內建規則。",
|
||||
"ai.privacy.patterns": "自訂正規表示式",
|
||||
"ai.privacy.patterns.description": "每行一個相容 RE2 的模式,不含 / 分隔符號或旗標。不支援或不安全的模式會遭拒絕。",
|
||||
"ai.privacy.connectionScope": "預留位置在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
|
||||
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用預留位置。",
|
||||
"ai.privacy.apply": "套用",
|
||||
"ai.privacy.applying": "正在套用...",
|
||||
"ai.privacy.applied": "AI 隱私設定已套用。請建立新的受管對話並重新連接外部 MCP 用戶端。",
|
||||
"ai.privacy.failed": "AI 隱私設定未套用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
|
||||
'ai.externalMcp.http.enable': '啟用 HTTP',
|
||||
'ai.externalMcp.http.port': '連接埠',
|
||||
'ai.externalMcp.http.portHint': '固定連接埠,預設 47391。重新啟動後用戶端網址維持不變。允許範圍:1024–65535。',
|
||||
'ai.externalMcp.http.allowCustomBind': '監聽其他位址',
|
||||
'ai.externalMcp.http.allowCustomBind.description': '預設只綁定 127.0.0.1。WSL2 NAT 可改為監聽 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenAddress': '監聽位址',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': '用戶端位址',
|
||||
'ai.externalMcp.http.clientHost.description': '監聽 0.0.0.0 或 :: 時,複製的設定會使用這個位址。請填用戶端連得到的 IP,例如 Windows 主機 IP。',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': '請填用戶端要連線的 IP。0.0.0.0 不是可連線的位址。',
|
||||
'ai.externalMcp.http.invalidClientHost': '用戶端位址必須是可連線的 IP,不能是 0.0.0.0 或 ::。',
|
||||
'ai.externalMcp.http.warning': '監聽 127.0.0.1 以外的位址,會讓能連到這台電腦的其他裝置使用對外 MCP。請保管 bearer token,不要把這個連接埠暴露到公開網路。',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': '重新產生',
|
||||
'ai.externalMcp.http.regenerateHint': '產生新的 bearer token。HTTP 用戶端必須改用新 token。stdio 的 discovery 檔會一併更新;已經透過 stdio 連上的用戶端維持連線。',
|
||||
'ai.externalMcp.http.wslHint': 'WSL 若使用鏡像網路,localhost 即可連線。否則請監聽 0.0.0.0 或 vEthernet IP,並把用戶端位址設成 Windows 主機 IP。',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': '通用 JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token 環境變數',
|
||||
'ai.externalMcp.http.notListening': '未監聽',
|
||||
'ai.externalMcp.http.listening': '監聽中',
|
||||
'ai.externalMcp.http.portInUse': '連接埠 {port} 已被占用。',
|
||||
'ai.externalMcp.http.requiresExternalMcp': '請先開啟對外 MCP,HTTP 端點才會開始監聽。',
|
||||
'ai.externalMcp.http.invalidPort': '請輸入 1024 到 65535 的連接埠。',
|
||||
'ai.externalMcp.http.invalidAddress': '監聽位址必須是 IP,例如 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP 監聽失敗。',
|
||||
'ai.externalMcp.http.legacySseNote': '不提供舊版 HTTP+SSE(2024-11-05)。請使用這個 Streamable HTTP 網址。',
|
||||
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '寫入權限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。',
|
||||
|
||||
@@ -787,6 +787,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 個主機',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
@@ -1141,6 +1142,10 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜尋主機或分頁',
|
||||
'qs.quickConnectTo': '快速連線到 {target}',
|
||||
'qs.hosts': '主機',
|
||||
'qs.tabs': '分頁',
|
||||
'qs.newWorkspace': '新增工作區',
|
||||
'qs.jumpTo': '跳轉到',
|
||||
'qs.localTerminal': '本機終端',
|
||||
'qs.localShells': '本機 Shell',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadKubernetes': '無法載入 Kubernetes 資源',
|
||||
'systemManager.errors.loadPods': '無法載入 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 無法連線到 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '無法連到內容 {{context}} 的叢集',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '無法連到叢集',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
@@ -132,7 +137,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
|
||||
'systemManager.tmux.noWindows': '沒有視窗',
|
||||
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
|
||||
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '沒有窗格',
|
||||
@@ -194,6 +199,36 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 個映像',
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器執行環境',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '內容',
|
||||
'systemManager.k8s.namespace': '命名空間',
|
||||
'systemManager.k8s.empty': '沒有 Pod',
|
||||
'systemManager.k8s.search': '搜尋 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 個 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '刪除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '確定刪除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重新啟動 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '選擇容器',
|
||||
'systemManager.k8s.noContext': '此主機沒有 Kubernetes 內容',
|
||||
'systemManager.k8s.status.Running': '執行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失敗',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '當機重試',
|
||||
'systemManager.k8s.status.ContainerCreating': '建立中',
|
||||
'systemManager.k8s.status.Terminating': '終止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '映像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取映像失敗',
|
||||
'systemManager.k8s.status.Error': '錯誤',
|
||||
'systemManager.k8s.status.Evicted': '已驅逐',
|
||||
'systemManager.k8s.status.OOMKilled': '記憶體不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
|
||||
@@ -454,6 +454,10 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '臨時連線允許舊版演算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式以 PuTTY 參數啟動,以及快速連線)會使用與主機「允許舊版演算法」相同的已棄用演算法。已儲存主機仍使用自己的設定。預設關閉,這些演算法有已知安全問題。',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': '臨時連線使用網路裝置模式',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式啟動以及快速連線)會以網路裝置模式連線。已儲存主機和群組仍使用自己的裝置型別。預設關閉。',
|
||||
'settings.terminal.connection.defaultProxy': '預設代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
|
||||
'settings.terminal.connection.defaultProxy.none': '無',
|
||||
@@ -483,8 +487,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
|
||||
|
||||
@@ -999,11 +1003,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': '伺服器需要額外的身分驗證。',
|
||||
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。',
|
||||
'keyboard.interactive.response': '回應',
|
||||
'keyboard.interactive.verificationCode': '驗證碼',
|
||||
'keyboard.interactive.enterCode': '輸入驗證碼',
|
||||
'keyboard.interactive.enterResponse': '輸入回應',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.submit': '送出',
|
||||
'keyboard.interactive.verifying': '驗證中...',
|
||||
'keyboard.interactive.savePassword': '儲存密碼',
|
||||
'keyboard.interactive.fillTotp': '填入驗證碼',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 金鑰密碼',
|
||||
|
||||
@@ -290,6 +290,35 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': '使用選取的 SSH 憑證;如果同時儲存了密碼,可在失敗後嘗試密碼。',
|
||||
'hostDetails.auth.mfaFirst': '優先互動式驗證',
|
||||
'hostDetails.auth.mfaFirst.desc': '適用於必須透過 keyboard-interactive 輸入密碼和 MFA 的伺服器。',
|
||||
'hostDetails.totp.title': '驗證器 (TOTP)',
|
||||
'hostDetails.totp.enable': '為這台主機儲存 TOTP 金鑰',
|
||||
'hostDetails.totp.setup': '設定…',
|
||||
'hostDetails.totp.edit': '編輯',
|
||||
'hostDetails.totp.modalTitle': '設定驗證器',
|
||||
'hostDetails.totp.replacePlaceholder': '貼上新金鑰以取代目前的金鑰',
|
||||
'hostDetails.totp.warning': '能開啟這個保險庫的人會同時取得驗證碼和密碼。請只在個人裝置上使用。',
|
||||
'hostDetails.totp.secret': '金鑰或 otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': '貼上 otpauth://totp/... 或 Base32 金鑰',
|
||||
'hostDetails.totp.importQr': '匯入 QR 碼圖片',
|
||||
'hostDetails.totp.scanScreen': '從螢幕掃描 QR 碼',
|
||||
'hostDetails.totp.scanning': '正在掃描螢幕…',
|
||||
'hostDetails.totp.importedAccount': '已匯入 {account}',
|
||||
'hostDetails.totp.preview': '目前驗證碼',
|
||||
'hostDetails.totp.countdown': '剩餘 {seconds} 秒',
|
||||
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自動送出',
|
||||
'hostDetails.totp.autoSubmitDesc': '每次連線只送出一次,且提示必須精確符合 JumpServer、Google Authenticator PAM,或你填寫的規則運算式。',
|
||||
'hostDetails.totp.promptPattern': '自訂提示規則',
|
||||
'hostDetails.totp.promptPatternPlaceholder': '可選的 RE2 規則運算式',
|
||||
'hostDetails.totp.clear': '移除驗證器',
|
||||
'hostDetails.totp.removeTitle': '移除驗證器?',
|
||||
'hostDetails.totp.removeMessage': '這台主機將不再保存這個 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.hotp': '不支援 HOTP。請使用 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.migration': '不支援 otpauth-migration QR 碼。請使用服務提供的單一帳號綁定 QR 碼。',
|
||||
'hostDetails.totp.error.invalid': '無法從該內容讀取 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.notFound': '沒有找到 TOTP QR 碼。',
|
||||
'hostDetails.totp.error.permission': '需要螢幕錄製權限。請在系統設定中允許 Netcatty 錄製螢幕後再試。',
|
||||
'hostDetails.totp.error.unavailable': '無法使用螢幕擷取。',
|
||||
'hostDetails.totp.error.pattern': '自訂提示規則不是有效的 RE2 規則運算式。',
|
||||
'hostDetails.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
|
||||
'hostDetails.identity.suggestions': '身分',
|
||||
'hostDetails.identity.missing': '身分不存在',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
openQuickConnectFromInput,
|
||||
registerQuickConnectOpener,
|
||||
type QuickConnectOpenRequest,
|
||||
} from "./quickConnectBridge.ts";
|
||||
|
||||
test("openQuickConnectFromInput reuses the registered quick connect wizard", () => {
|
||||
const seen: QuickConnectOpenRequest[] = [];
|
||||
const unregister = registerQuickConnectOpener((request) => {
|
||||
seen.push(request);
|
||||
});
|
||||
|
||||
assert.equal(openQuickConnectFromInput(" root@10.1.1.1:2222 "), true);
|
||||
assert.deepEqual(seen, [{
|
||||
target: { hostname: "10.1.1.1", username: "root", port: 2222 },
|
||||
warnings: [],
|
||||
}]);
|
||||
|
||||
unregister();
|
||||
assert.equal(openQuickConnectFromInput("10.1.1.1"), false);
|
||||
});
|
||||
|
||||
test("openQuickConnectFromInput ignores text that is not an address", () => {
|
||||
const seen: QuickConnectOpenRequest[] = [];
|
||||
const unregister = registerQuickConnectOpener((request) => {
|
||||
seen.push(request);
|
||||
});
|
||||
|
||||
assert.equal(openQuickConnectFromInput("core switch"), false);
|
||||
assert.deepEqual(seen, []);
|
||||
unregister();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
parseQuickConnectInputWithWarnings,
|
||||
type QuickConnectTarget,
|
||||
} from "../domain/quickConnect";
|
||||
|
||||
export interface QuickConnectOpenRequest {
|
||||
target: QuickConnectTarget;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type QuickConnectOpener = (request: QuickConnectOpenRequest) => void;
|
||||
|
||||
let opener: QuickConnectOpener | null = null;
|
||||
|
||||
/** Vault owns the single QuickConnectWizard. The switcher asks it to open. */
|
||||
export function registerQuickConnectOpener(next: QuickConnectOpener): () => void {
|
||||
opener = next;
|
||||
return () => {
|
||||
if (opener === next) opener = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function openQuickConnectFromInput(input: string): boolean {
|
||||
const parsed = parseQuickConnectInputWithWarnings(input);
|
||||
if (!parsed.target || !opener) return false;
|
||||
opener({ target: parsed.target, warnings: parsed.warnings });
|
||||
return true;
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
|
||||
import {
|
||||
hasReversibleModelPrivacy,
|
||||
setReversibleModelPrivacy,
|
||||
} from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
cleanupSdkAgentSessions,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -61,6 +72,92 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
|
||||
const service = createAiPrivacyService({ maxSessions: 3 });
|
||||
service.configure({ enabled: true });
|
||||
const sessions = [
|
||||
{ ...makeSession('privacy-orphan', 1, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
{ ...makeSession('privacy-live', 2, []), scope: { type: 'terminal' as const, targetId: 'open' } },
|
||||
{ ...makeSession('privacy-resumed', 3, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
];
|
||||
const aliases = sessions.map(session => {
|
||||
setReversibleModelPrivacy(session.id, true);
|
||||
return service.redact(session.id, '8.8.8.8');
|
||||
});
|
||||
const sdkCleanups: string[] = [];
|
||||
const privacyCleanups: string[] = [];
|
||||
const outputCleanups: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dispatchEvent: () => true,
|
||||
netcatty: {
|
||||
aiSdkAgentCleanup: async (id: string) => {
|
||||
sdkCleanups.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
aiPrivacyClearSession: async (id: string) => {
|
||||
privacyCleanups.push(id);
|
||||
service.clearSession(id);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteChatToolOutputsTemp: async (id: string) => {
|
||||
outputCleanups.push(id);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
assert.deepEqual(sdkCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(privacyCleanups, []);
|
||||
assert.deepEqual(outputCleanups, []);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.redact(session.id, '8.8.8.8'), aliases[index]);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
assert.throws(() => service.checkSession('privacy-next'), /session limit reached/);
|
||||
|
||||
cleanupDeletedAIChatSessions(['privacy-orphan']);
|
||||
|
||||
assert.deepEqual(privacyCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(outputCleanups, ['privacy-orphan']);
|
||||
assert.equal(hasReversibleModelPrivacy('privacy-orphan'), false);
|
||||
assert.equal(service.hasSession('privacy-orphan'), false);
|
||||
assert.throws(() => service.checkSession('privacy-orphan'), /start a new chat/);
|
||||
assert.equal(service.checkSession('privacy-next'), true);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
if (session.id === 'privacy-orphan') continue;
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
} finally {
|
||||
for (const session of sessions) setReversibleModelPrivacy(session.id, false);
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
isMissingDirectoryReplacePathError,
|
||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||
} from "./directoryReplacePromotion";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export interface DedicatedResumeDeps {
|
||||
@@ -179,9 +180,11 @@ export async function openTransferSftpSession(
|
||||
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
|
||||
// leave reuseTransport enabled so the main-process registry can reuse an
|
||||
// authenticated endpoint even when no terminal tab exists.
|
||||
// No SFTP-page host-key dialog owns this session id. An untrusted key must
|
||||
// fail the transfer immediately instead of sitting on the verifier TTL.
|
||||
const openOpts = wantDedicated
|
||||
? { ...credentials, reuseTransport: false as const }
|
||||
: credentials;
|
||||
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const }
|
||||
: { ...credentials, hostKeyUnattended: true as const };
|
||||
|
||||
// The main-process auth driver owns method ordering, fallback, MFA and
|
||||
// retry. A renderer-side key-then-password retry performs a second full SSH
|
||||
@@ -740,7 +743,7 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(error),
|
||||
error: unwrapSftpIpcError(error),
|
||||
needsAttention: !!err.dedicatedAttention,
|
||||
resetCheckpoint: !!err.resetCheckpoint,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,20 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("a dead SFTP channel is not retried into another dial (#3310)", async () => {
|
||||
assert.equal(isTransientTransferError(new Error("SFTP channel closed")), false);
|
||||
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
() => runWithTransferRetry(async () => {
|
||||
attempts += 1;
|
||||
throw new Error("SFTP channel closed");
|
||||
}, { retries: 1, delayMs: 0 }),
|
||||
/SFTP channel closed/,
|
||||
);
|
||||
assert.equal(attempts, 1, "channel death must not schedule a reconnect");
|
||||
});
|
||||
|
||||
test("runWithTransferRetry does not retry cancel", async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
|
||||
@@ -6,6 +6,9 @@ import { isSessionError } from "./errors";
|
||||
* path) should not be retried.
|
||||
*/
|
||||
export function isTransientTransferError(err: unknown): boolean {
|
||||
// The channel is gone. Retrying opens a new dial, and an OTP host then
|
||||
// sits on keyboard-interactive while the transfer stays active.
|
||||
if (err instanceof Error && /sftp channel closed/i.test(err.message)) return false;
|
||||
if (isSessionError(err)) return true;
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
@@ -957,7 +958,7 @@ export const useSftpConnections = ({
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
error: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
@@ -1263,14 +1264,14 @@ export const useSftpConnections = ({
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
error: unwrapSftpIpcError(err) || "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
: (unwrapSftpIpcError(err) || "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { connectionTotpFields } from "../../../domain/totpConfig";
|
||||
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
|
||||
import {
|
||||
findIncompleteProxyIdentityId,
|
||||
@@ -121,6 +122,7 @@ export const buildSftpHostCredentials = ({
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
...connectionTotpFields(jumpHost),
|
||||
privateKey: jumpKeyAuth.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpKeyAuth.passphrase,
|
||||
@@ -182,6 +184,7 @@ export const buildSftpHostCredentials = ({
|
||||
requiresMfa: !!host.requiresMfa,
|
||||
port: host.port || 22,
|
||||
password,
|
||||
...connectionTotpFields(host),
|
||||
privateKey: keyAuth.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: keyAuth.passphrase,
|
||||
|
||||
@@ -56,6 +56,166 @@ test(`failed ${direction} connection replaces Waiting with the actual error (#36
|
||||
});
|
||||
}
|
||||
|
||||
test("upload open uses known hosts trusted after the transfer callback was created (#3310)", async () => {
|
||||
const seen: string[] = [];
|
||||
let fingerprint = "";
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let firstStart: ReturnType<typeof useSftpTransfers>["startTransfer"] | undefined;
|
||||
const restore = installGlobals({});
|
||||
function Probe() {
|
||||
const captured = fingerprint;
|
||||
const acquireTransferSession = React.useCallback(async () => {
|
||||
seen.push(captured);
|
||||
throw new Error(`known-hosts:${captured || "empty"}`);
|
||||
}, [captured]);
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "known-hosts-after-trust-3310",
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession,
|
||||
});
|
||||
if (!firstStart) firstStart = ops.startTransfer;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
fingerprint = "trusted-this-session";
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await firstStart!(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.deepEqual(seen, ["trusted-this-session"]);
|
||||
const task = sftpTransferCenterStore.getOwnerTasks("known-hosts-after-trust-3310")[0];
|
||||
assert.equal(task?.error, "known-hosts:trusted-this-session");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("channel death fails the upload without a second session open (#3310)", async () => {
|
||||
let acquires = 0;
|
||||
let streams = 0;
|
||||
const restore = installGlobals({
|
||||
statLocal: async () => ({ size: 48, lastModified: 1 }),
|
||||
statSftp: async () => null,
|
||||
startStreamTransfer: async () => {
|
||||
streams += 1;
|
||||
return { error: "SFTP channel closed" };
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "channel-death-no-reconnect-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
acquires += 1;
|
||||
return {
|
||||
sftpId: "sftp-borrowed",
|
||||
release: () => undefined,
|
||||
discard: () => undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(streams, 1, "channel death must not start another transfer dial");
|
||||
assert.equal(acquires, 2, "work lease plus the file lease, and no retry open");
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.status, "failed");
|
||||
assert.equal(task?.error, "SFTP channel closed");
|
||||
assert.equal(
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId)
|
||||
.filter((row) => ["pending", "queued", "transferring", "pausing"].includes(row.status))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("transfer failure strips the Electron IPC wrapper (#3310)", async () => {
|
||||
const restore = installGlobals({});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "ipc-prefix-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:sftp:open': Error: Host key verification required for 127.0.0.1");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.error, "Host key verification required for 127.0.0.1");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
@@ -712,3 +872,127 @@ test("owned cancelled directory settles recoverable children without dropping co
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
for (const liveSize of [37, 3, 0]) {
|
||||
test(`same-pane local copy discovers ${liveSize} bytes after the listed file changes`, async () => {
|
||||
const pane = makePane("right");
|
||||
pane.files = [{ ...makePane("left").files[0], name: "edited.txt", size: 15 }];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statLocal: async (path: string) => path.endsWith("edited.txt")
|
||||
? { type: "file", size: liveSize, lastModified: 1758000002000 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => { started.push(options); return {}; },
|
||||
pauseTransfer: async () => ({ success: false }),
|
||||
resumeTransfer: async () => ({ success: false }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: `local-edited-${liveSize}`,
|
||||
getActivePane: () => pane,
|
||||
getPaneByConnectionId: () => pane,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "edited.txt", isDirectory: false }], "right", "right");
|
||||
});
|
||||
assert.equal(ops!.conflicts.length, 1);
|
||||
assert.equal(ops!.conflicts[0].newSize, liveSize, "conflict must show the saved file's current size");
|
||||
assert.equal(ops!.conflicts[0].newModified, 1758000002000);
|
||||
await act(async () => {
|
||||
await ops!.resolveConflict(ops!.conflicts[0].transferId, "duplicate", false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
assert.equal(started.length, 1);
|
||||
assert.equal(started[0].totalBytes, liveSize || undefined, "copy must not retain the cached 15-byte size");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("no-conflict local copy awaits discovery so a late stat cannot overwrite the settled total", async () => {
|
||||
const pane = makePane("right");
|
||||
pane.files = [{ ...makePane("left").files[0], name: "late.txt", size: 15 }];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
// Target conflict stat: missing path means the copy has no conflict.
|
||||
lstatLocal: async () => null,
|
||||
statLocal: async (path: string) => {
|
||||
if (!path.endsWith("late.txt")) return null;
|
||||
// Let the stream finish before the stat resolves — fired-and-forgotten
|
||||
// discovery used to patch the terminal task with this stale total.
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return { type: "file", size: 9_999, lastModified: 1758000002000 };
|
||||
},
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: 15,
|
||||
totalBytes: 15,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false }),
|
||||
resumeTransfer: async () => ({ success: false }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "local-late-stat",
|
||||
getActivePane: () => pane,
|
||||
getPaneByConnectionId: () => pane,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "late.txt", isDirectory: false }], "right", "right");
|
||||
});
|
||||
// Give the no longer pending deferred stat time to land if it leaked.
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 600)); });
|
||||
const row = sftpTransferCenterStore.getSnapshot().tasks.find(
|
||||
(candidate) => candidate.originalFileName === "late.txt",
|
||||
);
|
||||
assert.ok(row);
|
||||
assert.equal(row.status, "completed");
|
||||
assert.equal(
|
||||
row.transferredBytes,
|
||||
row.totalBytes,
|
||||
"a deferred stat must not desync the completed row's total from its transferred bytes",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
@@ -191,6 +191,21 @@ export const useSftpTransfers = ({
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
// startTransfer keeps the processTransfer closure from its first render.
|
||||
// Read the latest acquire fn so a host key trusted after that render is
|
||||
// included in the transfer open, instead of the empty list from mount.
|
||||
const acquireTransferSessionRef = useRef(acquireTransferSession);
|
||||
acquireTransferSessionRef.current = acquireTransferSession;
|
||||
const acquireTransferSessionLive = useCallback<NonNullable<UseSftpTransfersParams["acquireTransferSession"]>>(
|
||||
(hostId, transferId, connectHost) => {
|
||||
const acquire = acquireTransferSessionRef.current;
|
||||
if (!acquire) {
|
||||
return Promise.reject(new Error("SFTP transfer session acquisition is unavailable"));
|
||||
}
|
||||
return acquire(hostId, transferId, connectHost);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Ref so progress flushes always see the latest visibility without rebinding.
|
||||
const surfaceVisibleRef = useRef(surfaceVisible);
|
||||
surfaceVisibleRef.current = surfaceVisible;
|
||||
@@ -468,7 +483,7 @@ export const useSftpTransfers = ({
|
||||
setTransfers,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
acquireTransferSession,
|
||||
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
|
||||
});
|
||||
|
||||
const processTransfer = async (
|
||||
@@ -602,8 +617,9 @@ export const useSftpTransfers = ({
|
||||
// survive browse park and tab hide (pool holders, not panel map ids).
|
||||
if (!sourcePane.connection?.isLocal) {
|
||||
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
||||
if (acquireTransferSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
||||
const acquireSourceSession = acquireTransferSessionRef.current;
|
||||
if (acquireSourceSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
|
||||
sourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
if (!sourceSftpId) {
|
||||
@@ -615,8 +631,9 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!targetPane.connection?.isLocal) {
|
||||
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
|
||||
if (acquireTransferSession && targetHostId) {
|
||||
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
|
||||
const acquireTargetSession = acquireTransferSessionRef.current;
|
||||
if (acquireTargetSession && targetHostId) {
|
||||
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
|
||||
targetSftpId = targetWorkLease.sftpId;
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
@@ -811,8 +828,8 @@ export const useSftpTransfers = ({
|
||||
})();
|
||||
|
||||
// For single files: start size discovery without blocking the conflict
|
||||
// check, but keep the promise so an actual conflict can await fresh
|
||||
// size metadata before the dialog is shown.
|
||||
// check, but always settle the promise before any dialog or transfer
|
||||
// launch (bounded per-task; the task is already visible/cancellable).
|
||||
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
|
||||
|
||||
// Only await conflict check (fast single stat call)
|
||||
@@ -823,19 +840,23 @@ export const useSftpTransfers = ({
|
||||
// null means the plan size is still unknown. A verified 0-byte stat is 0,
|
||||
// not null, so an empty file does not pick up a stale listing size.
|
||||
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
|
||||
if (conflict && sizeDiscoveryPromise) {
|
||||
if (sizeDiscoveryPromise) {
|
||||
// The task's totalBytes may still be 0 here because discovery ran
|
||||
// deferred and updateTask never mutates the local task copy. Await
|
||||
// discovery (bounded per-task; the task is already visible/cancellable)
|
||||
// so the overwrite dialog shows the real source size, not 0 (#3559).
|
||||
// discovery even without a conflict so its statLocal updateTask cannot
|
||||
// resolve after the transfer finished and overwrite the bridge's
|
||||
// authoritative total with a stale late size (the conflict dialog also
|
||||
// needs it: #3559).
|
||||
const discovered = await sizeDiscoveryPromise;
|
||||
// Cancel during this stat must win before an apply-to-all Replace
|
||||
// deletes an existing symlink.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
if (discovered && discovered.size >= 0) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
if (conflict) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,7 +1139,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
updateTask({
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "Transfer failed",
|
||||
error: unwrapSftpIpcError(err) || "Transfer failed",
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
@@ -1267,25 +1288,24 @@ export const useSftpTransfers = ({
|
||||
// Prefer the fresh re-stat for remote files: a cached listing size can
|
||||
// be stale and undersized, which would silently truncate downloads
|
||||
// (#3559). Remote files without a live stat use plan 0 so the bridge
|
||||
// measures the live size; local sources keep the pane metadata.
|
||||
// measures the live size. Local files also start unknown so deferred
|
||||
// discovery reads their current size after edits, not the cached listing.
|
||||
const fileEntry = fileEntryMap?.get(file.name);
|
||||
const freshMetadata = freshRemoteMetadata.get(file.name);
|
||||
const fileSize = file.isDirectory
|
||||
? 0
|
||||
: freshMetadata
|
||||
? freshMetadata.size
|
||||
: sourcePane.connection!.isLocal
|
||||
? (fileEntry?.size ?? 0)
|
||||
: 0;
|
||||
// Only keep an mtime that is paired with trusted metadata (live re-stat,
|
||||
// local source size, or directory totals). A remote file whose re-stat
|
||||
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
|
||||
: 0;
|
||||
// Only keep an mtime paired with a live re-stat or directory totals.
|
||||
// A file without a live stat must stay fully unknown
|
||||
// (size 0 AND mtime 0 in the plan):
|
||||
// discoverTransferSize and the conflict check both treat a nonzero
|
||||
// sourceLastModified as proof the size is already known, so a stale
|
||||
// mtime would suppress the deferred source re-stat. The overwrite
|
||||
// dialog still gets the pane listing's cached entry via
|
||||
// discoverTransferSize's cachedListingStatForDialog fallback.
|
||||
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
|
||||
const sourceLastModified = !file.isDirectory && !freshMetadata
|
||||
? 0
|
||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||
|
||||
@@ -1504,8 +1524,8 @@ export const useSftpTransfers = ({
|
||||
try {
|
||||
const result = await retryExternalDragDropFileUpload(task, {
|
||||
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
|
||||
acquireTransferSession: acquireTransferSession
|
||||
? (hostId, id) => acquireTransferSession(hostId, id)
|
||||
acquireTransferSession: acquireTransferSessionRef.current
|
||||
? (hostId, id) => acquireTransferSessionLive(hostId, id)
|
||||
: undefined,
|
||||
startStreamTransfer: async (options) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -1997,8 +2017,9 @@ export const useSftpTransfers = ({
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
const acquireDownloadSession = acquireTransferSessionRef.current;
|
||||
if (acquireDownloadSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireDownloadSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
@@ -2064,7 +2085,7 @@ export const useSftpTransfers = ({
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const errMsg = unwrapSftpIpcError(err);
|
||||
patchDownload({
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ContainerRuntimeId, ContainerSourceId } from '../../../domain/systemManager/containerRuntime';
|
||||
import { isContainerSource } from '../../../domain/systemManager/containerRuntime';
|
||||
import { STORAGE_KEY_CONTAINER_RUNTIME } from '../../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export interface ContainerRuntimePreference {
|
||||
source: ContainerSourceId;
|
||||
engine?: ContainerRuntimeId;
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
type PreferenceStore = Record<string, ContainerRuntimePreference>;
|
||||
|
||||
const CONTAINER_ENGINES = new Set<ContainerRuntimeId>(['docker', 'podman', 'nerdctl']);
|
||||
|
||||
function readStore(): PreferenceStore {
|
||||
const value = localStorageAdapter.read<PreferenceStore>(STORAGE_KEY_CONTAINER_RUNTIME);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
export function readContainerRuntimePreference(hostId: string): ContainerRuntimePreference | null {
|
||||
if (!hostId) return null;
|
||||
const entry = readStore()[hostId];
|
||||
if (!entry || !isContainerSource(entry.source)) return null;
|
||||
return {
|
||||
source: entry.source,
|
||||
engine: entry.engine && CONTAINER_ENGINES.has(entry.engine) ? entry.engine : undefined,
|
||||
context: typeof entry.context === 'string' ? entry.context : undefined,
|
||||
namespace: typeof entry.namespace === 'string' ? entry.namespace : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeContainerRuntimePreference(hostId: string, entry: ContainerRuntimePreference): void {
|
||||
if (!hostId || !isContainerSource(entry.source)) return;
|
||||
const store = readStore();
|
||||
store[hostId] = entry;
|
||||
localStorageAdapter.write(STORAGE_KEY_CONTAINER_RUNTIME, store);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { useSystemManagerBackend } from '../useSystemManagerBackend';
|
||||
import { useStableTranslate } from '../useSystemManager';
|
||||
import type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
describeRuntimeFailure,
|
||||
firstContainerRuntime,
|
||||
isAutomaticRuntimeFallback,
|
||||
isKubectlForbiddenError,
|
||||
reconcileContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../../domain/systemManager/types';
|
||||
import {
|
||||
readContainerRuntimePreference,
|
||||
writeContainerRuntimePreference,
|
||||
} from './containerRuntimePreference';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface UseContainerRuntimeSelectionOptions {
|
||||
sessionId: string;
|
||||
hostKey: string;
|
||||
capabilities?: SessionCapabilities;
|
||||
backend: Backend;
|
||||
}
|
||||
|
||||
export function useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey,
|
||||
capabilities,
|
||||
backend,
|
||||
}: UseContainerRuntimeSelectionOptions) {
|
||||
const stableT = useStableTranslate();
|
||||
const detected = sourcesFromCapabilities(capabilities);
|
||||
const detectedKey = detected.join('|');
|
||||
const [source, setSource] = useState<ContainerSourceId | null>(() => {
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
return reconcileContainerSource(detected, null, remembered);
|
||||
});
|
||||
const [contextName, setContextName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.context ?? null
|
||||
));
|
||||
const [namespaceName, setNamespaceName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.namespace ?? null
|
||||
));
|
||||
const [contexts, setContexts] = useState<KubeContextInfo[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||||
const [scopeError, setScopeError] = useState<string | null>(null);
|
||||
const [scopeNonce, setScopeNonce] = useState(0);
|
||||
const skipPersist = useRef(true);
|
||||
const namespaceNameRef = useRef(namespaceName);
|
||||
namespaceNameRef.current = namespaceName;
|
||||
|
||||
useEffect(() => {
|
||||
skipPersist.current = true;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
setSource(reconcileContainerSource(detected, null, remembered));
|
||||
setContextName(remembered?.context ?? null);
|
||||
setNamespaceName(remembered?.namespace ?? null);
|
||||
setContexts([]);
|
||||
setNamespaces([]);
|
||||
setScopeError(null);
|
||||
// Detection is reconciled by the next effect so a host switch does not
|
||||
// keep the previous host's in-memory selection.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
setSource((current) => reconcileContainerSource(
|
||||
available,
|
||||
current,
|
||||
readContainerRuntimePreference(hostKey),
|
||||
));
|
||||
}, [capabilities, detectedKey, hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipPersist.current) {
|
||||
skipPersist.current = false;
|
||||
return;
|
||||
}
|
||||
if (!source) return;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
if (isAutomaticRuntimeFallback(remembered, source, available)) return;
|
||||
const engine = source === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: source;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes') return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({ sessionId, op: 'contexts' }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.contexts) {
|
||||
setContexts([]);
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
const available = result.contexts;
|
||||
const kept = contextName && available.some((item) => item.name === contextName)
|
||||
? contextName
|
||||
: null;
|
||||
const next = kept
|
||||
?? available.find((item) => item.current)?.name
|
||||
?? available[0]?.name
|
||||
?? null;
|
||||
setContexts(available);
|
||||
setContextName(next);
|
||||
if (!kept) {
|
||||
const configured = available.find((item) => item.name === next)?.namespace || 'default';
|
||||
setNamespaceName(next ? configured : null);
|
||||
setNamespaces([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes' || !contextName) return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({
|
||||
sessionId,
|
||||
op: 'namespaces',
|
||||
context: contextName,
|
||||
}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.namespaces) {
|
||||
const message = result.error || '';
|
||||
if (isKubectlForbiddenError(message)) {
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace || 'default';
|
||||
const fallback = namespaceNameRef.current || configured;
|
||||
setScopeError(null);
|
||||
setNamespaceName((current) => current || fallback);
|
||||
setNamespaces([fallback]);
|
||||
return;
|
||||
}
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
setNamespaces(result.namespaces);
|
||||
setNamespaceName((current) => {
|
||||
if (current && result.namespaces?.includes(current)) return current;
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace;
|
||||
if (configured && result.namespaces?.includes(configured)) return configured;
|
||||
if (result.namespaces?.includes('default')) return 'default';
|
||||
return result.namespaces?.[0] ?? null;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, contexts, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
const selectSource = useCallback((value: string) => {
|
||||
if (value !== 'docker' && value !== 'podman' && value !== 'nerdctl' && value !== 'kubernetes') return;
|
||||
setSource(value);
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
const engine = value === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: value;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source: value,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName]);
|
||||
|
||||
const selectContext = useCallback((value: string) => {
|
||||
setContextName(value);
|
||||
const configured = contexts.find((item) => item.name === value)?.namespace ?? null;
|
||||
setNamespaceName(configured);
|
||||
}, [contexts]);
|
||||
|
||||
const reloadScopes = useCallback(() => {
|
||||
setScopeNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const runtime: ContainerRuntimeId = source && source !== 'kubernetes' ? source : 'docker';
|
||||
const showRuntimeSwitcher = detected.length > 1;
|
||||
|
||||
return {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_PRIVACY, STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_MODE } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
readExternalMcpHttpEnabled,
|
||||
readExternalMcpHttpListenAddress,
|
||||
readExternalMcpHttpPort,
|
||||
readExternalMcpHttpSettings,
|
||||
writeExternalMcpHttpAllowCustomBind,
|
||||
writeExternalMcpHttpEnabled,
|
||||
writeExternalMcpHttpListenAddress,
|
||||
writeExternalMcpHttpPort,
|
||||
} from "./useExternalMcpToggleState.ts";
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
applyAIPrivacySettings,
|
||||
readAIPrivacySettings,
|
||||
applyExternalMcpRedactionSettings,
|
||||
readExternalMcpRedactionSettings,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
createExternalMcpStartupSyncPlan,
|
||||
getExternalMcpEnableGenerationForTests,
|
||||
@@ -105,11 +120,12 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
syncExternalMcpConfig(bridge);
|
||||
await syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
@@ -414,3 +430,361 @@ describe('syncExternalMcpStartupState generation guard', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("external MCP HTTP settings", () => {
|
||||
it("defaults HTTP off on loopback and round-trips the explicit listen option", () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpHttpSettings(), {
|
||||
enabled: false,
|
||||
port: 47391,
|
||||
allowCustomBind: false,
|
||||
listenAddress: "0.0.0.0",
|
||||
clientHost: "",
|
||||
});
|
||||
assert.equal(readExternalMcpHttpEnabled(), false);
|
||||
writeExternalMcpHttpEnabled(true);
|
||||
assert.equal(writeExternalMcpHttpPort(80), 47391);
|
||||
assert.equal(writeExternalMcpHttpPort(48080), 48080);
|
||||
writeExternalMcpHttpAllowCustomBind(true);
|
||||
writeExternalMcpHttpListenAddress("172.22.0.1");
|
||||
assert.equal(readExternalMcpHttpEnabled(), true);
|
||||
assert.equal(readExternalMcpHttpPort(), 48080);
|
||||
assert.equal(readExternalMcpHttpAllowCustomBind(), true);
|
||||
assert.equal(readExternalMcpHttpListenAddress(), "172.22.0.1");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Shared AI privacy settings', () => {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
redactPrivateIps: true,
|
||||
cidrs: ['192.0.2.0/24'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
};
|
||||
|
||||
it('defaults off and persists only after successful bridge validation', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), {
|
||||
enabled: false, redactPrivateIps: false, cidrs: [], patterns: [],
|
||||
});
|
||||
let resolveConfig: (value: unknown) => void;
|
||||
const apply = applyExternalMcpRedactionSettings(settings, {
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config, { redaction: settings });
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
});
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null);
|
||||
resolveConfig!({ ok: true });
|
||||
await apply;
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), null);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads legacy privacy preferences until a validated shared preference is applied', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null, 'reads must not migrate or discard data');
|
||||
const changed = { ...settings, redactPrivateIps: false };
|
||||
await applyAIPrivacySettings(changed, { externalMcpSetConfig: async () => ({ ok: true }) });
|
||||
assert.deepEqual(readAIPrivacySettings(), changed);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), changed, 'legacy API must read the same shared policy');
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), changed);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), settings, 'keep legacy storage intact');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to legacy settings when a shared preference exists, even if corrupt', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
const disabled = { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, disabled);
|
||||
assert.deepEqual(readAIPrivacySettings(), disabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_PRIVACY);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the shared policy even when External MCP is disabled', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, false);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('shared-policy');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`external:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['shared-policy', 'external:false']);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not persist rejected, unverified or failed privacy changes', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const changed = { ...settings, patterns: ['['] };
|
||||
for (const result of [{ ok: false, error: 'Invalid regular expression' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => result,
|
||||
}));
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
}), /Disconnected/);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs saved privacy settings before persistent startup enable', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`enabled:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at startup when saved privacy settings are malformed', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const saved of ['{', 'null', '{}', '{"enabled":"true"}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[2]}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[],"unexpected":true}']) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
const enabledCalls: boolean[] = [];
|
||||
let configCalls = 0;
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
configCalls += 1;
|
||||
return { ok: false, error: 'Shared AI privacy configuration is unavailable.' };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { enabledCalls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.match(plan.configError!, /Invalid saved/);
|
||||
assert.equal(configCalls, 1, 'invalidate shared privacy instead of applying unprotected defaults');
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved/);
|
||||
assert.equal(configCalls, 2);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when the backend rejects or cannot verify startup config', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const result of [{ ok: false, error: 'Invalid CIDR' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const enabledCalls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => result,
|
||||
externalMcpSetEnabled: async (enabled) => { enabledCalls.push(enabled); },
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.ok(plan.configError);
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the corrupt-settings error if shared policy invalidation cannot reach the bridge', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
const calls: boolean[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
throw new Error('Disconnected');
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { calls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.match(plan.configError!, /Invalid saved AI privacy settings/);
|
||||
assert.deepEqual(calls, [false]);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved AI privacy settings/);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not enable from a stale privacy snapshot changed during config sync', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.match(plan.configError!, /changed during startup/);
|
||||
await assert.rejects(syncExternalMcpConfig({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, { ...settings, cidrs: [] });
|
||||
return { ok: true };
|
||||
},
|
||||
}), /settings changed/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on a startup config transport failure', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(plan.configError, 'Disconnected');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('External MCP manual enable privacy gate', () => {
|
||||
it('waits for validated config and keeps rejected or superseded enables off', async () => {
|
||||
const React = await import('react');
|
||||
const { act, create } = await import('react-test-renderer');
|
||||
const { useExternalMcpToggleState } = await import('./useExternalMcpToggleState.ts');
|
||||
const restoreStorage = installMemoryLocalStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
const events = new EventTarget();
|
||||
let resolveConfig!: (value: unknown) => void;
|
||||
const calls: string[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
netcatty: bridge,
|
||||
location: { hash: '#/settings' },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
setInterval,
|
||||
clearInterval,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
let hook!: ReturnType<typeof useExternalMcpToggleState>;
|
||||
const Capture = () => { hook = useExternalMcpToggleState(); return null; };
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
await act(async () => { renderer = create(React.createElement(Capture)); });
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
assert.deepEqual(calls, ['config']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await act(async () => { resolveConfig({ ok: false, error: 'Invalid privacy rules' }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.match(hook.error!, /Invalid privacy rules/);
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { await hook.setEnabled(false); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false, 'a newer disable wins over a pending enable');
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
assert.equal(hook.enabled, true);
|
||||
assert.equal(readExternalMcpStoredEnabled(), true);
|
||||
assert.equal(hook.error, null);
|
||||
} finally {
|
||||
if (renderer) await act(async () => renderer!.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,21 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_PRIVACY,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_EXTERNAL_MCP_HTTP_PORT } from '../../domain/externalMcpHttp';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
@@ -33,6 +41,7 @@ let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
|
||||
let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
|
||||
/** Bumps on every intentional enable/disable so stale startup applies lose. */
|
||||
let externalMcpEnableGeneration = 0;
|
||||
let externalMcpConfigError: string | null = null;
|
||||
|
||||
function getWindowHash(hash?: string): string {
|
||||
if (typeof hash === 'string') return hash;
|
||||
@@ -70,6 +79,7 @@ export function resetExternalMcpStartupReadyForTests(): void {
|
||||
externalMcpStartupReadyWaitPromise = null;
|
||||
externalMcpStartupReadyWaitResolve = null;
|
||||
externalMcpEnableGeneration = 0;
|
||||
externalMcpConfigError = null;
|
||||
externalMcpStartupSyncPromise = null;
|
||||
}
|
||||
|
||||
@@ -104,10 +114,157 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
|
||||
return externalMcpStartupReadyWaitPromise;
|
||||
}
|
||||
|
||||
export type AIPrivacySettings = {
|
||||
enabled: boolean;
|
||||
redactPrivateIps: boolean;
|
||||
cidrs: string[];
|
||||
patterns: string[];
|
||||
};
|
||||
|
||||
export function defaultAIPrivacySettings(): AIPrivacySettings {
|
||||
return { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
}
|
||||
|
||||
/** Missing settings are opt-out; corrupt settings must never silently opt out. */
|
||||
export function readAIPrivacySettings(): AIPrivacySettings {
|
||||
// The shared key wins, including invalid data: never fall back to a weaker legacy policy.
|
||||
// Leave the legacy value intact for older installs; Apply writes only the validated shared key.
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY)
|
||||
?? localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION);
|
||||
if (stored === null) return defaultAIPrivacySettings();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(stored);
|
||||
} catch {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
const settings = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(settings).some((key) => !['enabled', 'redactPrivateIps', 'cidrs', 'patterns'].includes(key))
|
||||
|| typeof settings.enabled !== 'boolean'
|
||||
|| typeof settings.redactPrivateIps !== 'boolean'
|
||||
|| !Array.isArray(settings.cidrs) || !settings.cidrs.every((item) => typeof item === 'string')
|
||||
|| !Array.isArray(settings.patterns) || !settings.patterns.every((item) => typeof item === 'string')
|
||||
) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
redactPrivateIps: settings.redactPrivateIps,
|
||||
cidrs: [...settings.cidrs],
|
||||
patterns: [...settings.patterns],
|
||||
};
|
||||
}
|
||||
|
||||
function configResponseError(
|
||||
result: unknown,
|
||||
unverifiedMessage = 'External MCP settings could not be verified. The bridge has not been enabled.',
|
||||
): string | null {
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === true) return null;
|
||||
if (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error) {
|
||||
return result.error;
|
||||
}
|
||||
return unverifiedMessage;
|
||||
}
|
||||
|
||||
/** Validate in the main process before committing any privacy preference. */
|
||||
export async function applyAIPrivacySettings(
|
||||
settings: AIPrivacySettings,
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const result = await bridge?.externalMcpSetConfig?.({ redaction: settings });
|
||||
const error = configResponseError(result, 'AI privacy settings could not be verified. Try applying them again.');
|
||||
if (error) throw new Error(error);
|
||||
if (!localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings)) {
|
||||
throw new Error('AI privacy settings could not be saved. Apply them again before restarting.');
|
||||
}
|
||||
externalMcpConfigError = null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
}
|
||||
|
||||
function readAIPrivacyState() {
|
||||
try {
|
||||
return { settings: readAIPrivacySettings(), error: null as string | null };
|
||||
} catch (error) {
|
||||
return {
|
||||
settings: defaultAIPrivacySettings(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useAIPrivacyState() {
|
||||
const [state, setState] = useState(readAIPrivacyState);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const apply = useCallback(async (settings: AIPrivacySettings): Promise<boolean> => {
|
||||
setApplying(true);
|
||||
try {
|
||||
await applyAIPrivacySettings(settings);
|
||||
setState({ settings, error: null });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return false;
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY_AI_PRIVACY || event.key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
const onLocalChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_PRIVACY || key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, applying, apply };
|
||||
}
|
||||
|
||||
/** Compatibility exports for existing External MCP configuration callers. One shared policy only. */
|
||||
export type ExternalMcpRedactionSettings = AIPrivacySettings;
|
||||
export const defaultExternalMcpRedactionSettings = defaultAIPrivacySettings;
|
||||
export const readExternalMcpRedactionSettings = readAIPrivacySettings;
|
||||
export const applyExternalMcpRedactionSettings = applyAIPrivacySettings;
|
||||
export const useExternalMcpRedactionState = useAIPrivacyState;
|
||||
|
||||
export type ExternalMcpHttpSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
allowCustomBind: boolean;
|
||||
listenAddress: string;
|
||||
clientHost: string;
|
||||
};
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
mode?: ExternalMcpMode;
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
/** null invalidates shared privacy when persisted settings cannot be read safely. */
|
||||
redaction?: AIPrivacySettings | null;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
httpListenAddress?: string;
|
||||
httpClientHost?: string;
|
||||
};
|
||||
|
||||
type ExternalMcpBridge = {
|
||||
@@ -127,6 +284,7 @@ export type ExternalMcpStartupSyncPlan = {
|
||||
runtimeEnabled: boolean;
|
||||
storedEnabled: boolean;
|
||||
shouldPersistStoredEnabled: boolean;
|
||||
configError?: string;
|
||||
};
|
||||
|
||||
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
|
||||
@@ -241,6 +399,109 @@ export function writeSessionIdleTimeoutMinutes(minutes: number): number {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const MIN_EXTERNAL_MCP_HTTP_PORT = 1024;
|
||||
const MAX_EXTERNAL_MCP_HTTP_PORT = 65535;
|
||||
|
||||
export function normalizeExternalMcpHttpPort(value: number | null): number {
|
||||
if (!Number.isInteger(value)) return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
|
||||
if (value < MIN_EXTERNAL_MCP_HTTP_PORT || value > MAX_EXTERNAL_MCP_HTTP_PORT) {
|
||||
return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpHttpListenAddress(value: string | null): string {
|
||||
const address = (value ?? '').trim();
|
||||
if (!address) return '0.0.0.0';
|
||||
return address;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpEnabled(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED) ?? false;
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpEnabled(enabled: boolean): boolean {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED, enabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpPort(): number {
|
||||
return normalizeExternalMcpHttpPort(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpPort(port: number): number {
|
||||
const normalized = normalizeExternalMcpHttpPort(port);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpAllowCustomBind(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND) ?? false;
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpAllowCustomBind(allowCustomBind: boolean): boolean {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND, allowCustomBind);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND);
|
||||
return allowCustomBind;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpListenAddress(): string {
|
||||
return normalizeExternalMcpHttpListenAddress(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpListenAddress(listenAddress: string): string {
|
||||
const normalized = normalizeExternalMcpHttpListenAddress(listenAddress);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpHttpClientHost(value: string | null): string {
|
||||
return (value ?? '').trim();
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpClientHost(): string {
|
||||
return normalizeExternalMcpHttpClientHost(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpClientHost(clientHost: string): string {
|
||||
const normalized = normalizeExternalMcpHttpClientHost(clientHost);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpSettings(): ExternalMcpHttpSettings {
|
||||
return {
|
||||
enabled: readExternalMcpHttpEnabled(),
|
||||
port: readExternalMcpHttpPort(),
|
||||
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
|
||||
listenAddress: readExternalMcpHttpListenAddress(),
|
||||
clientHost: readExternalMcpHttpClientHost(),
|
||||
};
|
||||
}
|
||||
|
||||
function httpConfigFromSettings(settings: ExternalMcpHttpSettings): Pick<
|
||||
ExternalMcpConfig,
|
||||
'httpEnabled' | 'httpPort' | 'httpAllowCustomBind' | 'httpListenAddress' | 'httpClientHost'
|
||||
> {
|
||||
return {
|
||||
httpEnabled: settings.enabled,
|
||||
httpPort: settings.port,
|
||||
httpAllowCustomBind: settings.allowCustomBind,
|
||||
httpListenAddress: settings.listenAddress,
|
||||
httpClientHost: settings.clientHost,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
@@ -263,11 +524,21 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction = defaultAIPrivacySettings(),
|
||||
http = {
|
||||
enabled: false,
|
||||
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
allowCustomBind: false,
|
||||
listenAddress: '0.0.0.0',
|
||||
clientHost: '',
|
||||
},
|
||||
}: {
|
||||
enabled: boolean;
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
http?: ExternalMcpHttpSettings;
|
||||
redaction?: AIPrivacySettings;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -276,6 +547,8 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
storedEnabled,
|
||||
@@ -284,20 +557,44 @@ export function createExternalMcpStartupSyncPlan({
|
||||
}
|
||||
|
||||
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
return createExternalMcpStartupSyncPlan({
|
||||
let redaction = defaultAIPrivacySettings();
|
||||
let configError: string | undefined;
|
||||
try {
|
||||
redaction = readAIPrivacySettings();
|
||||
} catch (error) {
|
||||
configError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const plan = createExternalMcpStartupSyncPlan({
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
http: readExternalMcpHttpSettings(),
|
||||
redaction,
|
||||
});
|
||||
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
|
||||
}
|
||||
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
});
|
||||
export async function syncExternalMcpConfig(
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (plan.configError) {
|
||||
// A corrupt saved policy must block all managed agents, not only External MCP.
|
||||
// Keep the original recovery error even if notifying the main process fails.
|
||||
try {
|
||||
await bridge?.externalMcpSetConfig?.({ redaction: null });
|
||||
} catch {
|
||||
// The saved policy still needs user repair regardless of bridge availability.
|
||||
}
|
||||
throw new Error(plan.configError);
|
||||
}
|
||||
const result = await bridge?.externalMcpSetConfig?.(plan.config);
|
||||
const error = configResponseError(result);
|
||||
if (error) throw new Error(error);
|
||||
if (JSON.stringify(readAIPrivacySettings()) !== JSON.stringify(plan.config.redaction)) {
|
||||
throw new Error('AI privacy settings changed. Try enabling the bridge again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,10 +616,18 @@ export async function syncExternalMcpStartupState(
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
let configError = initialPlan.configError;
|
||||
try {
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
// Config sync is best-effort; continue with enable/disable reconcile.
|
||||
if (configError) {
|
||||
// Explicit invalidation is distinct from a rejected draft Apply, which keeps
|
||||
// the previous working policy. Never replace corrupt saved data with defaults.
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.({ redaction: null }));
|
||||
} else {
|
||||
const result = await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
configError = configResponseError(result) ?? undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
configError ??= error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// A user toggle during config await invalidates this startup apply.
|
||||
@@ -331,6 +636,18 @@ export async function syncExternalMcpStartupState(
|
||||
}
|
||||
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (JSON.stringify(plan.config.redaction) !== JSON.stringify(initialPlan.config.redaction)) {
|
||||
configError = 'AI privacy settings changed during startup. Enable the bridge again.';
|
||||
}
|
||||
if (configError || plan.configError) {
|
||||
// A rejected or unverified config must never expose an unprotected runtime.
|
||||
plan.configError = configError || plan.configError;
|
||||
plan.runtimeEnabled = false;
|
||||
plan.storedEnabled = false;
|
||||
plan.shouldPersistStoredEnabled = readExternalMcpStoredEnabled();
|
||||
}
|
||||
externalMcpConfigError = plan.configError ?? null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
if (plan.shouldPersistStoredEnabled) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
@@ -382,24 +699,47 @@ export function useExternalMcpToggleState() {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
persistEnabled(nextEnabled);
|
||||
// Peer session windows can mirror the stored switch, but they must not own
|
||||
// the main-process External MCP lifecycle.
|
||||
if (isPeerSessionWindow) return;
|
||||
externalMcpEnableGeneration += 1;
|
||||
// Re-emit after the lifecycle IPC settles so consumers that refetch runtime
|
||||
// status (e.g. Tool Access guidance) do not act on the pre-IPC storage
|
||||
// event, which can still observe the previous enabled state.
|
||||
void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
|
||||
const [error, setError] = useState<string | null>(() => externalMcpConfigError);
|
||||
|
||||
const setEnabled = useCallback(async (nextEnabled: boolean) => {
|
||||
// Peer session windows mirror storage, but never own the main lifecycle.
|
||||
if (isPeerSessionWindow) {
|
||||
persistEnabled(nextEnabled);
|
||||
return;
|
||||
}
|
||||
if (!nextEnabled) persistEnabled(false);
|
||||
const generation = ++externalMcpEnableGeneration;
|
||||
setError(null);
|
||||
externalMcpConfigError = null;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
// Even a manual enable must apply and verify saved privacy settings first.
|
||||
if (nextEnabled) await syncExternalMcpConfig(bridge);
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
const result = await bridge?.externalMcpSetEnabled?.(nextEnabled);
|
||||
if (nextEnabled && result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
throw new Error('error' in result && typeof result.error === 'string' && result.error
|
||||
? result.error : 'External MCP could not be enabled.');
|
||||
}
|
||||
if (generation === externalMcpEnableGeneration) persistEnabled(nextEnabled);
|
||||
} catch (cause) {
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
externalMcpConfigError = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(externalMcpConfigError);
|
||||
if (nextEnabled) {
|
||||
persistEnabled(false);
|
||||
await Promise.resolve(bridge?.externalMcpSetEnabled?.(false)).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
});
|
||||
}
|
||||
}, [isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorage = () => {
|
||||
const nextEnabled = readExternalMcpStoredEnabled();
|
||||
setEnabledRaw(nextEnabled);
|
||||
setError(externalMcpConfigError);
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
@@ -408,7 +748,7 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return;
|
||||
syncFromStorage();
|
||||
};
|
||||
|
||||
@@ -453,5 +793,5 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
}, [enabled, isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
return { enabled, setEnabled };
|
||||
return { enabled, setEnabled, error };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import type {
|
||||
DockerContainerAction,
|
||||
DockerImageManageAction,
|
||||
@@ -94,23 +95,27 @@ export function useSystemManagerBackend() {
|
||||
return bridge.tmuxAction(options);
|
||||
}, []);
|
||||
|
||||
const listDockerContainers = useCallback(async (sessionId: string) => {
|
||||
const listDockerContainers = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerContainers) {
|
||||
return { success: false as const, error: 'listDockerContainers unavailable' };
|
||||
}
|
||||
return bridge.listDockerContainers(sessionId);
|
||||
return bridge.listDockerContainers(sessionId, runtime);
|
||||
}, []);
|
||||
|
||||
const listDockerImages = useCallback(async (sessionId: string) => {
|
||||
const listDockerImages = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerImages) {
|
||||
return { success: false as const, error: 'listDockerImages unavailable' };
|
||||
}
|
||||
return bridge.listDockerImages(sessionId);
|
||||
return bridge.listDockerImages(sessionId, runtime);
|
||||
}, []);
|
||||
|
||||
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
|
||||
const getDockerStats = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
ids?: string[];
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getDockerStats) {
|
||||
return { success: false as const, error: 'getDockerStats unavailable' };
|
||||
@@ -155,7 +160,11 @@ export function useSystemManagerBackend() {
|
||||
return bridge.systemServiceAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const dockerInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
return { success: false as const, error: 'dockerInspect unavailable' };
|
||||
@@ -163,7 +172,11 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
|
||||
const dockerImageInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
imageId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageInspect) {
|
||||
return { success: false as const, error: 'dockerImageInspect unavailable' };
|
||||
@@ -176,6 +189,7 @@ export function useSystemManagerBackend() {
|
||||
containerId: string;
|
||||
action: DockerContainerAction;
|
||||
newName?: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerAction) {
|
||||
@@ -184,7 +198,10 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
|
||||
const dockerImageAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
} & DockerImageManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageAction) {
|
||||
return { success: false as const, error: 'dockerImageAction unavailable' };
|
||||
@@ -192,6 +209,21 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerImageAction(options);
|
||||
}, []);
|
||||
|
||||
const kubectl = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
op: 'contexts' | 'namespaces' | 'pods' | 'delete';
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
pod?: string;
|
||||
shell?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.kubectl) {
|
||||
return { success: false as const, error: 'kubectl unavailable' };
|
||||
}
|
||||
return bridge.kubectl(options);
|
||||
}, []);
|
||||
|
||||
const openTerminalPopup = useCallback(async (
|
||||
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
|
||||
) => {
|
||||
@@ -223,6 +255,7 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
}), [
|
||||
probeSystemCapabilities,
|
||||
@@ -245,6 +278,7 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
|
||||
import type { Host } from "../types";
|
||||
import { applyHostAuthMethodSelection } from "../domain/sshAuth";
|
||||
import { HostTotpFields } from "./HostTotpFields";
|
||||
import { HostIconPicker } from "./HostIconPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
@@ -101,6 +102,7 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
totpFlushRef,
|
||||
}) => {
|
||||
const selectAuthMethod = (authMethod: "auto" | "password" | "key" | "certificate") => {
|
||||
setForm((previous: Host) => applyEffectiveHostAuthMethodSelection(
|
||||
@@ -739,6 +741,13 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HostTotpFields
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
effectiveProtocol={effectiveProtocol}
|
||||
flushRef={totpFlushRef}
|
||||
/>
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ test("HostDetailsPanel labels command proxy summaries consistently", () => {
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
assert.doesNotMatch(markup, /--token secret/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
||||
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
||||
import type { HostTotpFlushResult } from "./HostTotpFields";
|
||||
import {
|
||||
LINUX_DISTRO_OPTION_IDS,
|
||||
parseOptionalPortInput,
|
||||
@@ -163,6 +164,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
};
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const baselineSnippetsRef = useRef(snippets);
|
||||
const totpFlushRef = useRef<(() => Promise<HostTotpFlushResult>) | null>(null);
|
||||
const savingHostRef = useRef(false);
|
||||
const baselineHydratedRef = useRef(snippets.length > 0);
|
||||
const openingQueueIdsRef = useRef<string[]>([]);
|
||||
const [form, setForm] = useState<Host>(
|
||||
@@ -534,7 +537,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol))
|
||||
: hasHostname;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (savingHostRef.current) return;
|
||||
savingHostRef.current = true;
|
||||
try {
|
||||
const flushed = await totpFlushRef.current?.();
|
||||
if (flushed && !flushed.ok) return;
|
||||
const hostname = form.hostname.trim()
|
||||
|| (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : "");
|
||||
if (!hostname || !canSaveHost) return;
|
||||
@@ -615,6 +623,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
authPolicyVersion: 1,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
if (flushed?.ok) {
|
||||
if (flushed.totp) cleaned.totp = flushed.totp;
|
||||
else delete cleaned.totp;
|
||||
}
|
||||
if (pluginProtocolActive) {
|
||||
const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol);
|
||||
if (!pluginConnection) return;
|
||||
@@ -742,6 +754,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
}
|
||||
}
|
||||
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
|
||||
} finally {
|
||||
savingHostRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
@@ -1137,6 +1152,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
totpFlushRef={totpFlushRef}
|
||||
update={update}
|
||||
groupDefaults={effectiveGroupDefaults}
|
||||
effectiveAuthMethod={effectiveAuthMethod}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Host } from "../domain/models";
|
||||
|
||||
test("host totp setup stays on the shared setting row and opens a dialog", 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>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
install("window", dom.window);
|
||||
install("document", dom.window.document);
|
||||
install("navigator", dom.window.navigator);
|
||||
install("HTMLElement", dom.window.HTMLElement);
|
||||
install("HTMLInputElement", dom.window.HTMLInputElement);
|
||||
install("HTMLButtonElement", dom.window.HTMLButtonElement);
|
||||
install("Element", dom.window.Element);
|
||||
install("Node", dom.window.Node);
|
||||
install("MutationObserver", dom.window.MutationObserver);
|
||||
install("Event", dom.window.Event);
|
||||
install("CustomEvent", dom.window.CustomEvent);
|
||||
install("MouseEvent", dom.window.MouseEvent);
|
||||
install("KeyboardEvent", dom.window.KeyboardEvent);
|
||||
install("FocusEvent", dom.window.FocusEvent);
|
||||
install("PointerEvent", dom.window.PointerEvent);
|
||||
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("ResizeObserver", ResizeObserverStub);
|
||||
install("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
Object.assign(dom.window, { netcatty: {} });
|
||||
|
||||
const { act } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { HostTotpFields } = await import("./HostTotpFields.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
|
||||
const form = {
|
||||
id: "host-1",
|
||||
label: "Jump",
|
||||
hostname: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
let latest = form;
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const t = (key: string) => key;
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<HostTotpFields
|
||||
t={t}
|
||||
form={latest}
|
||||
setForm={(updater) => {
|
||||
latest = typeof updater === "function" ? updater(latest) : updater;
|
||||
}}
|
||||
effectiveProtocol="ssh"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
const markup = dom.window.document.body.innerHTML;
|
||||
assert.match(markup, /hostDetails\.totp\.setup/);
|
||||
assert.doesNotMatch(markup, /rounded-md border border-border\/60 p-3/);
|
||||
assert.equal(dom.window.document.querySelector("[role='dialog']"), null);
|
||||
assert.equal(dom.window.document.querySelector("[role='switch']"), null);
|
||||
|
||||
const button = [...dom.window.document.querySelectorAll("button")]
|
||||
.find((node) => node.textContent?.includes("hostDetails.totp.setup"));
|
||||
assert.ok(button);
|
||||
await act(async () => { button.click(); });
|
||||
assert.ok(dom.window.document.body.textContent?.includes("hostDetails.totp.modalTitle"));
|
||||
assert.ok(dom.window.document.body.innerHTML.includes("hostDetails.totp.secretPlaceholder"));
|
||||
assert.equal(latest.totp, undefined);
|
||||
const fields = readFileSync(new URL("./HostTotpFields.tsx", import.meta.url), "utf8");
|
||||
assert.match(fields, /label=\{t\("hostDetails\.totp\.title"\)\}/);
|
||||
assert.match(fields, /setConfirmClear\(true\)/);
|
||||
assert.match(fields, /variant="secondary"/);
|
||||
const sections = readFileSync(new URL("./HostDetailsConnectionSections.tsx", import.meta.url), "utf8");
|
||||
const passwordAt = sections.indexOf("hostDetails.password.placeholder");
|
||||
const totpAt = sections.indexOf("<HostTotpFields");
|
||||
assert.ok(passwordAt > 0 && totpAt > passwordAt);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import jsQR from "jsqr";
|
||||
import { RE2JS } from "re2js";
|
||||
import type { Host, HostTotpConfig } from "../domain/models";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { HostDetailsSettingRow } from "./host-details";
|
||||
import { SystemPanelConfirmDialog } from "./systemManager/SystemPanelConfirmDialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
|
||||
type Preview = {
|
||||
uri: string;
|
||||
code: string;
|
||||
secondsRemaining: number;
|
||||
issuer?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type HostTotpFlushResult =
|
||||
| { ok: true; totp: HostTotpConfig | undefined }
|
||||
| { ok: false };
|
||||
|
||||
type HostTotpFieldsProps = {
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
form: Host;
|
||||
setForm: (updater: Host | ((host: Host) => Host)) => void;
|
||||
effectiveProtocol?: string;
|
||||
flushRef?: React.MutableRefObject<(() => Promise<HostTotpFlushResult>) | null>;
|
||||
};
|
||||
|
||||
const HIDDEN_PROTOCOLS = new Set(["telnet", "serial"]);
|
||||
|
||||
function errorKey(error: string | undefined): string {
|
||||
switch (error) {
|
||||
case "hotp":
|
||||
return "hostDetails.totp.error.hotp";
|
||||
case "migration":
|
||||
return "hostDetails.totp.error.migration";
|
||||
case "not-found":
|
||||
return "hostDetails.totp.error.notFound";
|
||||
case "screen-permission-denied":
|
||||
return "hostDetails.totp.error.permission";
|
||||
case "unavailable":
|
||||
return "hostDetails.totp.error.unavailable";
|
||||
case "pattern":
|
||||
return "hostDetails.totp.error.pattern";
|
||||
default:
|
||||
return "hostDetails.totp.error.invalid";
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeQrFile(file: File): Promise<string> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("unavailable");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const image = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return jsQR(image.data, image.width, image.height)?.data || "";
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
function validPattern(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length > 200) return null;
|
||||
try {
|
||||
RE2JS.compile(trimmed);
|
||||
return trimmed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function importedAccount(preview: Preview): string {
|
||||
const issuer = preview.issuer?.trim() || "";
|
||||
const label = preview.label?.trim() || "";
|
||||
if (issuer && label && !label.toLowerCase().startsWith(issuer.toLowerCase())) {
|
||||
return `${issuer} (${label})`;
|
||||
}
|
||||
return label || issuer;
|
||||
}
|
||||
|
||||
function useLiveTotp(source: string | undefined, hostId: string): Preview | null {
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const hostRef = useRef(hostId);
|
||||
hostRef.current = hostId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
setPreview(null);
|
||||
return undefined;
|
||||
}
|
||||
const expectedHostId = hostId;
|
||||
let cancelled = false;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.previewTotp) return undefined;
|
||||
const endsAtRef = { current: 0 };
|
||||
let refreshed = false;
|
||||
const apply = (next: Preview) => {
|
||||
if (cancelled || hostRef.current !== expectedHostId) return;
|
||||
endsAtRef.current = Date.now() + next.secondsRemaining * 1000;
|
||||
refreshed = false;
|
||||
setPreview(next);
|
||||
};
|
||||
void bridge.previewTotp({ text: source }).then((result) => {
|
||||
if (!result?.ok || !result.uri || !result.code) return;
|
||||
apply({
|
||||
uri: result.uri,
|
||||
code: result.code,
|
||||
secondsRemaining: result.secondsRemaining || result.period || 30,
|
||||
issuer: result.issuer,
|
||||
label: result.label,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
const timer = setInterval(() => {
|
||||
if (cancelled || !endsAtRef.current) return;
|
||||
const left = Math.max(0, Math.ceil((endsAtRef.current - Date.now()) / 1000));
|
||||
setPreview((current) => (
|
||||
current && current.secondsRemaining !== left
|
||||
? { ...current, secondsRemaining: left }
|
||||
: current
|
||||
));
|
||||
if (left !== 0 || refreshed) return;
|
||||
refreshed = true;
|
||||
void bridge.previewTotp({ text: source }).then((result) => {
|
||||
if (!result?.ok || !result.uri || !result.code) return;
|
||||
apply({
|
||||
uri: result.uri,
|
||||
code: result.code,
|
||||
secondsRemaining: result.secondsRemaining || result.period || 30,
|
||||
issuer: result.issuer,
|
||||
label: result.label,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
}, 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [hostId, source]);
|
||||
|
||||
return source ? preview : null;
|
||||
}
|
||||
|
||||
export const HostTotpFields: React.FC<HostTotpFieldsProps> = ({
|
||||
t,
|
||||
form,
|
||||
setForm,
|
||||
effectiveProtocol,
|
||||
flushRef,
|
||||
}) => {
|
||||
const configured = Boolean(form.totp?.uri);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [pattern, setPattern] = useState(form.totp?.promptPattern || "");
|
||||
const [autoSubmit, setAutoSubmit] = useState(form.totp?.autoSubmit !== false);
|
||||
const [modalUri, setModalUri] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const hostIdRef = useRef(form.id);
|
||||
const formRef = useRef(form);
|
||||
const setupOpenRef = useRef(setupOpen);
|
||||
const draftRef = useRef(draft);
|
||||
const patternRef = useRef(pattern);
|
||||
const autoSubmitRef = useRef(autoSubmit);
|
||||
const modalUriRef = useRef(modalUri);
|
||||
const requestRef = useRef(0);
|
||||
formRef.current = form;
|
||||
setupOpenRef.current = setupOpen;
|
||||
draftRef.current = draft;
|
||||
patternRef.current = pattern;
|
||||
autoSubmitRef.current = autoSubmit;
|
||||
modalUriRef.current = modalUri;
|
||||
|
||||
const [trackedHostId, setTrackedHostId] = useState(form.id);
|
||||
if (form.id !== trackedHostId) {
|
||||
setTrackedHostId(form.id);
|
||||
setSetupOpen(false);
|
||||
setDraft("");
|
||||
setPattern(form.totp?.promptPattern || "");
|
||||
setAutoSubmit(form.totp?.autoSubmit !== false);
|
||||
setModalUri(undefined);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
setConfirmClear(false);
|
||||
draftRef.current = "";
|
||||
patternRef.current = form.totp?.promptPattern || "";
|
||||
autoSubmitRef.current = form.totp?.autoSubmit !== false;
|
||||
modalUriRef.current = undefined;
|
||||
setupOpenRef.current = false;
|
||||
}
|
||||
hostIdRef.current = form.id;
|
||||
|
||||
const rowPreview = useLiveTotp(form.totp?.uri, form.id);
|
||||
const modalPreview = useLiveTotp(setupOpen ? (modalUri || form.totp?.uri) : undefined, form.id);
|
||||
|
||||
const writeTotp = (next: HostTotpConfig | undefined) => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
setForm((current) => {
|
||||
if (current.id !== expectedHostId) return current;
|
||||
const updated = { ...current };
|
||||
if (next?.uri) updated.totp = next;
|
||||
else delete updated.totp;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const previewText = async (text: string): Promise<string | null> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
const requestId = ++requestRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.previewTotp) {
|
||||
setError("unavailable");
|
||||
return null;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bridge.previewTotp({ text });
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return null;
|
||||
if (!result?.ok || !result.uri || !result.code) {
|
||||
setError(result?.error || "invalid");
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
setModalUri(result.uri);
|
||||
modalUriRef.current = result.uri;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
return result.uri;
|
||||
} catch {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
|
||||
return null;
|
||||
} finally {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitSetup = async (options?: { requireSecret?: boolean }): Promise<HostTotpFlushResult> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
const patternValue = validPattern(patternRef.current);
|
||||
if (patternValue == null) {
|
||||
setError("pattern");
|
||||
return { ok: false };
|
||||
}
|
||||
let uri = modalUriRef.current || formRef.current.totp?.uri;
|
||||
const text = draftRef.current.trim();
|
||||
if (text) {
|
||||
uri = await previewText(text) || undefined;
|
||||
if (hostIdRef.current !== expectedHostId) return { ok: false };
|
||||
if (!uri) return { ok: false };
|
||||
}
|
||||
if (!uri) {
|
||||
if (options?.requireSecret) {
|
||||
setError("invalid");
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, totp: formRef.current.totp };
|
||||
}
|
||||
const next: HostTotpConfig = {
|
||||
uri,
|
||||
autoSubmit: autoSubmitRef.current,
|
||||
...(patternValue ? { promptPattern: patternValue } : {}),
|
||||
};
|
||||
writeTotp(next);
|
||||
setSetupOpen(false);
|
||||
setupOpenRef.current = false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
return { ok: true, totp: next };
|
||||
};
|
||||
const commitRef = useRef(commitSetup);
|
||||
commitRef.current = commitSetup;
|
||||
|
||||
useEffect(() => {
|
||||
if (!flushRef) return undefined;
|
||||
const flush = async (): Promise<HostTotpFlushResult> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
if (hostIdRef.current !== expectedHostId) return { ok: false };
|
||||
if (!setupOpenRef.current) return { ok: true, totp: formRef.current.totp };
|
||||
return commitRef.current();
|
||||
};
|
||||
flushRef.current = flush;
|
||||
return () => {
|
||||
if (flushRef.current === flush) flushRef.current = null;
|
||||
};
|
||||
}, [flushRef]);
|
||||
|
||||
const openSetup = () => {
|
||||
setPattern(form.totp?.promptPattern || "");
|
||||
patternRef.current = form.totp?.promptPattern || "";
|
||||
setAutoSubmit(form.totp?.autoSubmit !== false);
|
||||
autoSubmitRef.current = form.totp?.autoSubmit !== false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
setSetupOpen(true);
|
||||
setupOpenRef.current = true;
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
requestRef.current += 1;
|
||||
setSetupOpen(false);
|
||||
setupOpenRef.current = false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const clearTotp = () => {
|
||||
cancelSetup();
|
||||
writeTotp(undefined);
|
||||
};
|
||||
|
||||
if (effectiveProtocol && HIDDEN_PROTOCOLS.has(effectiveProtocol)) return null;
|
||||
|
||||
const scanScreen = async () => {
|
||||
const expectedHostId = form.id;
|
||||
const requestId = ++requestRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.scanTotpQrFromScreen) {
|
||||
setError("unavailable");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await bridge.scanTotpQrFromScreen();
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
if (!result?.ok || !result.uri || !result.code) {
|
||||
setError(result?.error || "not-found");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setModalUri(result.uri);
|
||||
modalUriRef.current = result.uri;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
} catch {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
|
||||
} finally {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) {
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.totp.title")}
|
||||
hint={t("hostDetails.totp.warning")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{rowPreview && (
|
||||
<span
|
||||
className="font-mono text-xs font-normal tabular-nums tracking-widest text-muted-foreground"
|
||||
title={t("hostDetails.totp.countdown", { seconds: rowPreview.secondsRemaining })}
|
||||
>
|
||||
{rowPreview.code}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={openSetup}
|
||||
>
|
||||
{configured ? t("hostDetails.totp.edit") : t("hostDetails.totp.setup")}
|
||||
</Button>
|
||||
</div>
|
||||
</HostDetailsSettingRow>
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={(open) => { if (!open) cancelSetup(); }}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("hostDetails.totp.modalTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("hostDetails.totp.warning")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host-totp-secret">{t("hostDetails.totp.secret")}</Label>
|
||||
<Input
|
||||
id="host-totp-secret"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
draftRef.current = event.target.value;
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (draftRef.current.trim()) void previewText(draftRef.current);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commitSetup({ requireSecret: true });
|
||||
}
|
||||
}}
|
||||
placeholder={configured ? t("hostDetails.totp.replacePlaceholder") : t("hostDetails.totp.secretPlaceholder")}
|
||||
aria-label={t("hostDetails.totp.secret")}
|
||||
className="h-8 font-mono text-xs"
|
||||
disabled={busy}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{t("hostDetails.totp.importQr")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => { void scanScreen(); }}
|
||||
>
|
||||
{scanning ? t("hostDetails.totp.scanning") : t("hostDetails.totp.scanScreen")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const expectedHostId = form.id;
|
||||
const requestId = ++requestRef.current;
|
||||
setBusy(true);
|
||||
void decodeQrFile(file)
|
||||
.then((text) => {
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
if (!text) {
|
||||
setError("not-found");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
return previewText(text);
|
||||
})
|
||||
.catch(() => {
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
setError("invalid");
|
||||
setBusy(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{modalPreview && (
|
||||
<div className="space-y-1">
|
||||
{importedAccount(modalPreview) ? (
|
||||
<p className="text-sm text-foreground">
|
||||
{t("hostDetails.totp.importedAccount", { account: importedAccount(modalPreview) })}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("hostDetails.totp.preview")}
|
||||
{" "}
|
||||
<span className="font-mono tracking-widest text-foreground">{modalPreview.code}</span>
|
||||
<span className="ml-2">{t("hostDetails.totp.countdown", { seconds: modalPreview.secondsRemaining })}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{t(errorKey(error))}</p>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{t("hostDetails.totp.autoSubmit")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.totp.autoSubmitDesc")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoSubmit}
|
||||
onCheckedChange={(checked) => {
|
||||
setAutoSubmit(checked);
|
||||
autoSubmitRef.current = checked;
|
||||
}}
|
||||
aria-label={t("hostDetails.totp.autoSubmit")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host-totp-pattern">{t("hostDetails.totp.promptPattern")}</Label>
|
||||
<Input
|
||||
id="host-totp-pattern"
|
||||
value={pattern}
|
||||
onChange={(event) => {
|
||||
setPattern(event.target.value);
|
||||
patternRef.current = event.target.value;
|
||||
setError((current) => (current === "pattern" ? null : current));
|
||||
}}
|
||||
placeholder={t("hostDetails.totp.promptPatternPlaceholder")}
|
||||
aria-label={t("hostDetails.totp.promptPattern")}
|
||||
className="h-8 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
{configured ? (
|
||||
<Button type="button" variant="ghost" onClick={() => setConfirmClear(true)}>
|
||||
{t("hostDetails.totp.clear")}
|
||||
</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={cancelSetup}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => { void commitSetup({ requireSecret: true }); }} disabled={busy}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{confirmClear ? (
|
||||
<SystemPanelConfirmDialog
|
||||
open
|
||||
destructive
|
||||
title={t("hostDetails.totp.removeTitle")}
|
||||
message={t("hostDetails.totp.removeMessage")}
|
||||
confirmLabel={t("hostDetails.totp.clear")}
|
||||
onOpenChange={(open) => { if (!open) setConfirmClear(false); }}
|
||||
onConfirm={() => {
|
||||
setConfirmClear(false);
|
||||
clearTotp();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -235,6 +235,7 @@ test("HostTreeView exposes selected hosts to keyboard and assistive technology",
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-depth="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*aria-level="1"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*tabindex="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("HostTreeView exposes only one tree item in the tab order", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../domain/hostClickBehavior';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
|
||||
import { isVaultHostDraggable } from '../domain/vaultHostDrag';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
@@ -25,6 +26,8 @@ import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
import { handleVaultHostDropToGroup } from './vault/vaultReorderDrag';
|
||||
import { beginVaultHostCardDrag } from './vault/vaultHostDragImage';
|
||||
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
|
||||
|
||||
const getTreeGroupDropIntent = (
|
||||
@@ -293,6 +296,7 @@ interface HostTreeViewProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -334,6 +338,7 @@ interface TreeNodeProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -381,6 +386,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -526,9 +532,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(null);
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
if (handleVaultHostDropToGroup({
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
})) return;
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
|
||||
moveGroup(groupPath, node.path);
|
||||
}
|
||||
@@ -598,6 +609,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -774,11 +786,16 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
? 0
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId: host.id,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) e.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
@@ -909,6 +926,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -1219,6 +1237,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -1311,6 +1330,7 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
|
||||
&& prev.onEditGroup === next.onEditGroup
|
||||
&& prev.onDeleteGroup === next.onDeleteGroup
|
||||
&& prev.moveHostToGroup === next.moveHostToGroup
|
||||
&& prev.moveHostsToGroup === next.moveHostsToGroup
|
||||
&& prev.moveGroup === next.moveGroup
|
||||
&& prev.commitInlineGroupRename === next.commitInlineGroupRename
|
||||
&& prev.cancelInlineGroupEdit === next.cancelInlineGroupEdit
|
||||
|
||||
@@ -4,48 +4,94 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { formatKeyboardInteractiveServerPrompt } from "./KeyboardInteractiveModal.tsx";
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
import {
|
||||
keyboardInteractiveFieldLabel,
|
||||
keyboardInteractiveGuidance,
|
||||
totpFillPlacement,
|
||||
visibleTotpFillPlacements,
|
||||
} from "./KeyboardInteractiveModal.tsx";
|
||||
|
||||
const modalSource = readFileSync(new URL("./KeyboardInteractiveModal.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("formatKeyboardInteractiveServerPrompt preserves server instructions and prompt labels", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "Keyboard-interactive authentication prompts from server",
|
||||
instructions: "为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Secondary Authentication Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
[
|
||||
"Keyboard-interactive authentication prompts from server:",
|
||||
"| 为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
"| Secondary Authentication Password:",
|
||||
].join("\n"),
|
||||
);
|
||||
test("keyboardInteractiveFieldLabel uses the cleaned last prompt line", () => {
|
||||
assert.equal(keyboardInteractiveFieldLabel("Secondary Authentication Password:", "Verification code"), "Secondary Authentication Password");
|
||||
assert.equal(keyboardInteractiveFieldLabel("JumpServer:\n| SMS code:", "Verification code"), "SMS code");
|
||||
assert.equal(keyboardInteractiveFieldLabel("[OTP Code]: ", "Verification code"), "OTP Code");
|
||||
assert.equal(keyboardInteractiveFieldLabel(" ", "Verification code"), "Verification code");
|
||||
});
|
||||
|
||||
test("formatKeyboardInteractiveServerPrompt omits hostname-only fallback prompts", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "192.168.9.138",
|
||||
instructions: "",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
test("keyboard-interactive dialog shows a field label and an inline TOTP action", async () => {
|
||||
assert.doesNotMatch(modalSource, /<pre/);
|
||||
assert.match(modalSource, /keyboardInteractiveFieldLabel\(/);
|
||||
assert.match(modalSource, /keyboardInteractiveGuidance\(/);
|
||||
assert.match(modalSource, /data-totp-fill=\{fillPlacement\}/);
|
||||
assert.match(modalSource, /variant="secondary"/);
|
||||
assert.doesNotMatch(modalSource, /variant=\{fillPlacement/);
|
||||
assert.match(modalSource, /totpPrompt\.shared\.mjs/);
|
||||
const shared = require("../domain/totpPrompt.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
|
||||
const runtime = require("../domain/totp.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
|
||||
const esm = await import("../domain/totpPrompt.shared.mjs");
|
||||
assert.equal(shared.NON_TOTP_PROMPT, runtime.NON_TOTP_PROMPT);
|
||||
assert.equal(esm.NON_TOTP_PROMPT.source, shared.NON_TOTP_PROMPT.source);
|
||||
assert.doesNotMatch(modalSource, /ring-primary/);
|
||||
assert.doesNotMatch(modalSource, /className="w-full"/);
|
||||
assert.doesNotMatch(modalSource, /onTypeTotp/);
|
||||
});
|
||||
|
||||
assert.equal(text, "");
|
||||
test("keyboardInteractiveGuidance keeps directions that are not already the field label", () => {
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Verification code:", echo: false }],
|
||||
}), "");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "Duo Security",
|
||||
hostname: "vpn.example",
|
||||
instructions: "1. Duo Push\n2. SMS\nPasscode or option (1-3):",
|
||||
prompts: [{ prompt: "Passcode or option (1-3):", echo: true }],
|
||||
}), "Duo Security\n1. Duo Push\n2. SMS");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "host",
|
||||
hostname: "other",
|
||||
instructions: "| Verification code:",
|
||||
prompts: [{ prompt: "Verification code:", echo: false }],
|
||||
}), "host");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }],
|
||||
}), "1. OTP\n2. SMS");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "1. OTP",
|
||||
prompts: [{ prompt: "1. OTP\nOption>", echo: true }],
|
||||
}), "1. OTP");
|
||||
});
|
||||
|
||||
test("TOTP fill stays off password prompts and prefers verification prompts", () => {
|
||||
assert.equal(totpFillPlacement({ prompt: "Password:", echo: false }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "Verification code:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "[OTP Code]:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "One-time password:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "SMS code:", echo: false }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "[Y/n]", echo: true }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "1. OTP\nVerification code:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "Response:", echo: true }), "available");
|
||||
assert.deepEqual(visibleTotpFillPlacements([
|
||||
{ prompt: "Password:", echo: false },
|
||||
{ prompt: "Verification code:", echo: false },
|
||||
]), ["hidden", "preferred"]);
|
||||
assert.deepEqual(visibleTotpFillPlacements([
|
||||
{ prompt: "Password:", echo: false },
|
||||
]), ["hidden"]);
|
||||
});
|
||||
|
||||
test("keyboard-interactive modal cannot be dismissed by outside click or Escape", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NON_TOTP_PROMPT } from "../domain/totpPrompt.shared.mjs";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -34,35 +35,54 @@ export interface KeyboardInteractiveRequest {
|
||||
savedPassword?: string | null;
|
||||
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
|
||||
allowSavePassword?: boolean;
|
||||
/** otpauth URI from the connection attempt that opened this dialog. */
|
||||
totpUri?: string;
|
||||
}
|
||||
|
||||
type KeyboardInteractiveServerPromptInput = Pick<
|
||||
KeyboardInteractiveRequest,
|
||||
"name" | "instructions" | "prompts" | "hostname"
|
||||
>;
|
||||
/** Last meaningful line of a server prompt, without the raw banner. */
|
||||
export function keyboardInteractiveFieldLabel(prompt: string | undefined, fallback: string): string {
|
||||
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const last = lines.at(-1) || "";
|
||||
const cleaned = last
|
||||
.replace(/^[\s||>•*-]+/, "")
|
||||
.replace(/[::]\s*$/, "")
|
||||
.trim()
|
||||
.replace(/^\[(.+)\]$/, "$1")
|
||||
.trim();
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
/** Formats the server-supplied keyboard-interactive prompt block for display. */
|
||||
export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string {
|
||||
const lines: string[] = [];
|
||||
const name = request.name?.trim();
|
||||
const hostname = request.hostname?.trim();
|
||||
const instructions = request.instructions?.trim();
|
||||
const hasServerText = !!instructions || !!(name && name !== hostname);
|
||||
if (!hasServerText) return "";
|
||||
if (name && name !== hostname) {
|
||||
lines.push(name.endsWith(":") ? name : `${name}:`);
|
||||
}
|
||||
if (instructions) {
|
||||
for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) {
|
||||
lines.push(`| ${line}`);
|
||||
}
|
||||
}
|
||||
function guidanceKeys(text: string): string[] {
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
const label = keyboardInteractiveFieldLabel(text, "").toLowerCase();
|
||||
return [trimmed, label].filter(Boolean);
|
||||
}
|
||||
|
||||
/** Server directions that are not already the field label. Plain text, not a prompt dump. */
|
||||
export function keyboardInteractiveGuidance(request: Pick<KeyboardInteractiveRequest, "name" | "instructions" | "prompts" | "hostname">): string {
|
||||
const labelKeys = new Set<string>();
|
||||
const promptLines: string[] = [];
|
||||
for (const prompt of request.prompts || []) {
|
||||
const promptText = prompt.prompt?.trim();
|
||||
if (promptText) {
|
||||
lines.push(`| ${promptText}`);
|
||||
}
|
||||
const parts = String(prompt?.prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const last = parts.at(-1) || "";
|
||||
for (const key of guidanceKeys(last)) labelKeys.add(key);
|
||||
promptLines.push(...parts.slice(0, -1));
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
const push = (text: string) => {
|
||||
const line = text.trim().replace(/^\|\s*/, "");
|
||||
if (!line) return;
|
||||
const keys = guidanceKeys(line);
|
||||
if (keys.some((key) => labelKeys.has(key) || seen.has(key))) return;
|
||||
for (const key of keys) seen.add(key);
|
||||
lines.push(line);
|
||||
};
|
||||
const hostname = (request.hostname || "").trim().toLowerCase();
|
||||
const name = (request.name || "").trim();
|
||||
if (name && name.toLowerCase() !== hostname) push(name);
|
||||
for (const part of String(request.instructions || "").split(/\r?\n/)) push(part);
|
||||
for (const part of promptLines) push(part);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -107,12 +127,43 @@ interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>;
|
||||
onCancel: (requestId: string) => boolean | Promise<boolean>;
|
||||
canFillTotp?: boolean;
|
||||
onFillTotp?: () => Promise<string | null> | string | null;
|
||||
}
|
||||
|
||||
const OTP_PROMPT = /otp|one[-\s]?time|totp|verification|passcode|authenticator|\bmfa\b|\b2fa\b|two[-\s]?factor|验证码|驗證碼|动态口令|動態口令|二次认证|二次認證|二次验证|二次驗證/i;
|
||||
|
||||
export type TotpFillPlacement = "preferred" | "available" | "hidden";
|
||||
|
||||
function lastPromptLine(prompt: string | undefined): string {
|
||||
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
return lines.at(-1) || "";
|
||||
}
|
||||
|
||||
/** Where a TOTP fill action may appear. Password prompts never receive the code. */
|
||||
export function totpFillPlacement(prompt: KeyboardInteractivePrompt): TotpFillPlacement {
|
||||
const rawLast = lastPromptLine(prompt.prompt);
|
||||
const label = keyboardInteractiveFieldLabel(prompt.prompt, "");
|
||||
if (!label || NON_TOTP_PROMPT.test(rawLast) || NON_TOTP_PROMPT.test(label)) return "hidden";
|
||||
if (OTP_PROMPT.test(label)) return "preferred";
|
||||
if (isAPasswordPrompt({ prompt: label, echo: prompt.echo })) return "hidden";
|
||||
if (/\b(password|passwd|passphrase)\b/i.test(label)) return "hidden";
|
||||
return "available";
|
||||
}
|
||||
|
||||
export function visibleTotpFillPlacements(prompts: KeyboardInteractivePrompt[]): TotpFillPlacement[] {
|
||||
const placements = prompts.map(totpFillPlacement);
|
||||
const hasPreferred = placements.some((placement) => placement === "preferred");
|
||||
if (!hasPreferred) return placements;
|
||||
return placements.map((placement) => (placement === "preferred" ? placement : "hidden"));
|
||||
}
|
||||
|
||||
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
canFillTotp = false,
|
||||
onFillTotp,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
@@ -185,6 +236,15 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
}
|
||||
}, [request, onCancel, isSubmitting]);
|
||||
|
||||
const handleFillTotp = useCallback(async (index: number) => {
|
||||
if (!request || isSubmitting || !onFillTotp) return;
|
||||
const prompt = request.prompts[index];
|
||||
if (!prompt || totpFillPlacement(prompt) === "hidden") return;
|
||||
const code = await onFillTotp();
|
||||
if (!code) return;
|
||||
handleResponseChange(index, code);
|
||||
}, [request, isSubmitting, onFillTotp, handleResponseChange]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting) {
|
||||
@@ -197,12 +257,14 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const serverPromptText = formatKeyboardInteractiveServerPrompt(request);
|
||||
const title = t("keyboard.interactive.title");
|
||||
const description =
|
||||
request.hostname
|
||||
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
|
||||
: t("keyboard.interactive.desc");
|
||||
const fillPlacements = canFillTotp ? visibleTotpFillPlacements(request.prompts) : [];
|
||||
const preferredFillIndex = fillPlacements.findIndex((placement) => placement === "preferred");
|
||||
const guidance = keyboardInteractiveGuidance(request);
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}>
|
||||
@@ -227,26 +289,40 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{serverPromptText && (
|
||||
<pre className="max-h-36 overflow-auto rounded-md border border-border/60 bg-muted/45 px-3 py-2 text-xs leading-relaxed text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{serverPromptText}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{guidance ? (
|
||||
<p className="max-h-36 overflow-auto whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{guidance}
|
||||
</p>
|
||||
) : null}
|
||||
{request.prompts.map((prompt, index) => {
|
||||
const isPassword = !prompt.echo;
|
||||
const showPassword = showPasswords[index];
|
||||
const showPromptLabel = !(serverPromptText && request.prompts.length === 1);
|
||||
// Clean up prompt text (remove trailing colon and whitespace)
|
||||
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
|
||||
const promptLabel = keyboardInteractiveFieldLabel(
|
||||
prompt.prompt,
|
||||
t("keyboard.interactive.verificationCode"),
|
||||
);
|
||||
const fillPlacement = fillPlacements[index] ?? "hidden";
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
{showPromptLabel && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`ki-prompt-${index}`}>
|
||||
{promptLabel || t("keyboard.interactive.response")}
|
||||
{promptLabel}
|
||||
</Label>
|
||||
)}
|
||||
{fillPlacement !== "hidden" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2 font-normal focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
data-totp-fill={fillPlacement}
|
||||
onClick={() => { void handleFillTotp(index); }}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("keyboard.interactive.fillTotp")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`ki-prompt-${index}`}
|
||||
@@ -256,7 +332,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className={isPassword ? "pr-10" : undefined}
|
||||
autoFocus={index === 0}
|
||||
autoFocus={preferredFillIndex >= 0 ? index === preferredFillIndex : index === 0}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{isPassword && (
|
||||
@@ -288,7 +364,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig"
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { connectionTotpFields } from "../domain/totpConfig";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
@@ -258,6 +259,7 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
enableKeyboardInteractive: true,
|
||||
requiresMfa: !!effectiveExportHost.requiresMfa,
|
||||
...connectionTotpFields(effectiveExportHost),
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
@@ -671,7 +672,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
: `${protocolLabel} ${hostEndpoint}`
|
||||
);
|
||||
|
||||
return (
|
||||
const wizard = (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
@@ -798,6 +799,13 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// The vault surface is visibility:hidden while a terminal tab is active.
|
||||
// Portal so the same wizard opened from the tab-bar switcher stays visible.
|
||||
if (typeof document !== "undefined" && document.body) {
|
||||
return createPortal(wizard, document.body);
|
||||
}
|
||||
return wizard;
|
||||
};
|
||||
|
||||
export default QuickConnectWizard;
|
||||
@@ -0,0 +1,163 @@
|
||||
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 type { Host, TerminalSession } from "../types";
|
||||
import { QuickSwitcher } from "./QuickSwitcher";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "saved-1",
|
||||
label: "core-switch",
|
||||
hostname: "10.1.1.1",
|
||||
username: "admin",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
}) as Host;
|
||||
|
||||
const renderSwitcher = (
|
||||
query: string,
|
||||
results: Host[] = [],
|
||||
locale = "en",
|
||||
) => renderToStaticMarkup(
|
||||
<I18nProvider locale={locale}>
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query={query}
|
||||
results={results}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("quick switcher keeps a matching host ahead of an address row", () => {
|
||||
const markup = renderSwitcher("10.1.1.1", [host()]);
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.1.1.1");
|
||||
const hostAt = markup.indexOf("core-switch");
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.ok(selectedAt >= 0);
|
||||
assert.ok(hostAt > selectedAt);
|
||||
assert.ok(quickConnectAt > hostAt);
|
||||
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
|
||||
});
|
||||
|
||||
test("quick switcher does not steal Enter from a saved host label", () => {
|
||||
for (const label of ["lab", "web01", "prod", "lab-switch"]) {
|
||||
const markup = renderSwitcher(label, [host({ id: label, label, hostname: "10.0.0.5" })]);
|
||||
const hostAt = markup.indexOf(`>${label}<`);
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.equal(markup.includes("Quick connect to"), false, label);
|
||||
assert.ok(selectedAt >= 0 && hostAt > selectedAt, label);
|
||||
}
|
||||
});
|
||||
|
||||
test("quick switcher keeps a matching open session ahead of an address row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query="10.4.4.4"
|
||||
results={[]}
|
||||
sessions={[{
|
||||
id: "sess-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "open-core",
|
||||
username: "admin",
|
||||
hostname: "10.4.4.4",
|
||||
status: "connected",
|
||||
} as TerminalSession]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const sessionAt = markup.indexOf("open-core");
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.4.4.4");
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.ok(selectedAt >= 0);
|
||||
assert.ok(sessionAt > selectedAt);
|
||||
assert.ok(quickConnectAt > sessionAt);
|
||||
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
|
||||
});
|
||||
|
||||
test("quick switcher offers quick connect when nothing else matches", () => {
|
||||
const markup = renderSwitcher("root@10.2.0.8:2222");
|
||||
|
||||
assert.match(markup, /Quick connect to root@10\.2\.0\.8:2222/);
|
||||
assert.match(markup, /bg-primary\/15[\s\S]*Quick connect to root@10\.2\.0\.8:2222/);
|
||||
});
|
||||
|
||||
test("quick switcher does not offer quick connect for ordinary search text", () => {
|
||||
const markup = renderSwitcher("core switch");
|
||||
|
||||
assert.doesNotMatch(markup, /Quick connect to/);
|
||||
});
|
||||
|
||||
test("quick switcher quick connect row uses zh-TW wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-TW">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query="10.0.0.8"
|
||||
results={[host()]}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
onCreateWorkspace={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /快速連線到 10\.0\.0\.8/);
|
||||
assert.match(markup, />主機</);
|
||||
assert.match(markup, />新增工作區</);
|
||||
assert.doesNotMatch(markup, /New Workspace/);
|
||||
});
|
||||
|
||||
test("quick switcher section labels use zh-CN wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-CN">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query=""
|
||||
results={[host()]}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
onCreateWorkspace={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, />主机</);
|
||||
assert.match(markup, />标签页</);
|
||||
assert.match(markup, />新建工作区</);
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import type { PaneMagnificationState } from "../domain/paneMagnification";
|
||||
import { isSwitcherQuickConnectInput } from "../domain/quickConnect";
|
||||
import { matchesSearchQuery } from "../lib/searchMatcher";
|
||||
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
@@ -30,11 +32,12 @@ type QuickSwitcherItemBase = {
|
||||
shortcut?: string;
|
||||
pluginId?: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
quickConnectInput?: string;
|
||||
};
|
||||
|
||||
type QuickSwitcherItem = QuickSwitcherItemBase & (
|
||||
| { type: "plugin-command"; commandId: string }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view" | "quick-connect"; commandId?: never }
|
||||
);
|
||||
|
||||
export function buildPaneMagnificationPaletteAction(
|
||||
@@ -165,6 +168,7 @@ interface QuickSwitcherProps {
|
||||
paneMagnificationState?: PaneMagnificationState;
|
||||
onMagnifyCurrentPane?: () => void;
|
||||
onRestoreMagnifiedPane?: () => void;
|
||||
onQuickConnect?: (input: string) => void;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
@@ -186,6 +190,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
paneMagnificationState = 'unavailable',
|
||||
onMagnifyCurrentPane,
|
||||
onRestoreMagnifiedPane,
|
||||
onQuickConnect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
@@ -335,19 +340,37 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", "Hosts");
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
}
|
||||
const quickConnectItem: QuickSwitcherItem | null =
|
||||
trimmedQuery && isSwitcherQuickConnectInput(trimmedQuery)
|
||||
? {
|
||||
type: "quick-connect",
|
||||
id: "quick-connect",
|
||||
title: t("qs.quickConnectTo", { target: trimmedQuery }),
|
||||
quickConnectInput: trimmedQuery,
|
||||
}
|
||||
: null;
|
||||
|
||||
const hasTabsSection =
|
||||
builtInTabs.length > 0
|
||||
|| filteredOrphanSessions.length > 0
|
||||
|| filteredWorkspaces.length > 0;
|
||||
|
||||
// The address row is the default Enter target only when nothing else matches.
|
||||
// Matching hosts and open tabs stay ahead of it.
|
||||
if (quickConnectItem && results.length === 0 && !hasTabsSection) {
|
||||
pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", t("qs.hosts"));
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
if (quickConnectItem) pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (hasTabsSection) {
|
||||
pushHeader("header:tabs", "Tabs");
|
||||
pushHeader("header:tabs", t("qs.tabs"));
|
||||
builtInTabs.forEach((tabId) => {
|
||||
pushItem({ type: "tab", id: tabId });
|
||||
});
|
||||
@@ -357,6 +380,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
filteredOrphanSessions.forEach((s) =>
|
||||
pushItem({ type: "tab", id: s.id, data: s }),
|
||||
);
|
||||
if (quickConnectItem && results.length === 0) pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (filteredShells.length > 0) {
|
||||
@@ -445,6 +469,10 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
const handleItemSelect = (item: QuickSwitcherItem, useAlternate = false) => {
|
||||
switch (item.type) {
|
||||
case "quick-connect":
|
||||
if (item.quickConnectInput) onQuickConnect?.(item.quickConnectInput);
|
||||
onClose();
|
||||
break;
|
||||
case "host":
|
||||
onSelect(item.data as Host);
|
||||
break;
|
||||
@@ -542,7 +570,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
<span>{t("qs.newWorkspace")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -576,6 +604,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
// Fixed virtual slots: keep content single-line (or plugin two-line) and clip overflow.
|
||||
const rowClass = `flex h-full min-h-0 items-center gap-3 overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`;
|
||||
|
||||
if (item.type === "quick-connect") {
|
||||
return (
|
||||
<div
|
||||
className={rowClass}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="shrink-0 flex items-center justify-center bg-primary text-primary-foreground h-5 w-5 rounded-[5px]">
|
||||
<Zap className="h-3 w-3" fill="currentColor" />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "host") {
|
||||
const host = item.data as Host;
|
||||
const hostRow = (
|
||||
|
||||
+28
-11
@@ -59,7 +59,7 @@ import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPas
|
||||
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
||||
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
|
||||
import {
|
||||
appendTerminalPromptSecurityTail,
|
||||
foldTerminalPromptSecurityOutput,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
isUntrustedTerminalInputPrompt,
|
||||
} from "../domain/terminalPromptSecurity";
|
||||
@@ -163,7 +163,10 @@ import {
|
||||
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
|
||||
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
|
||||
import {
|
||||
isIdleShellReadyForCommandInjection,
|
||||
isShellPromptReadyForInsertion,
|
||||
} from "../domain/terminalCommandInjectionReady";
|
||||
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
|
||||
import type { SensitiveInputActiveRef } from "./terminal/autocomplete/terminalAutocompletePrompt";
|
||||
import {
|
||||
@@ -449,6 +452,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
), [sessionId]);
|
||||
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
|
||||
const sensitivePromptOutputTailRef = useRef("");
|
||||
const heredocPromptOriginRef = useRef("");
|
||||
const promptSecurityPendingCrRef = useRef(false);
|
||||
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
|
||||
const dismissedScriptRunIdsRef = useRef(new Set<string>());
|
||||
|
||||
@@ -600,18 +605,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const onSessionExitRef = useRef(onSessionExit);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, (options) => {
|
||||
const term = termRef.current;
|
||||
if (!term) return false;
|
||||
const prompt = detectPrompt(term);
|
||||
return isIdleShellReadyForCommandInjection({
|
||||
const readiness = {
|
||||
sensitiveInputActive: passwordPromptActiveRef.current,
|
||||
hasLiveTerminal: true,
|
||||
alternateScreenActive: isTerminalAlternateScreenActive(term),
|
||||
isAtPrompt: prompt.isAtPrompt,
|
||||
userInputLength: prompt.userInput.length,
|
||||
pendingTypedInputLength: commandBufferRef.current.length,
|
||||
});
|
||||
};
|
||||
return options?.allowDraft
|
||||
? isShellPromptReadyForInsertion(readiness)
|
||||
: isIdleShellReadyForCommandInjection(readiness);
|
||||
}), [sessionId]);
|
||||
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
@@ -2005,10 +2013,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
chunk: string,
|
||||
meta?: TerminalSessionDataMeta,
|
||||
) => {
|
||||
sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
chunk,
|
||||
);
|
||||
const folded = foldTerminalPromptSecurityOutput({
|
||||
origin: heredocPromptOriginRef.current,
|
||||
current: sensitivePromptOutputTailRef.current,
|
||||
pendingCr: promptSecurityPendingCrRef.current,
|
||||
}, chunk);
|
||||
heredocPromptOriginRef.current = folded.origin;
|
||||
sensitivePromptOutputTailRef.current = folded.current;
|
||||
promptSecurityPendingCrRef.current = folded.pendingCr;
|
||||
const promptSecurityOptions = {
|
||||
allowHostStyleGreaterThan: isNetworkDevice,
|
||||
alternateScreen: termRef.current
|
||||
@@ -2024,17 +2036,19 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
autocompleteCloseRef.current?.();
|
||||
} else {
|
||||
sensitivePromptOutputTailRef.current = "";
|
||||
heredocPromptOriginRef.current = "";
|
||||
promptSecurityPendingCrRef.current = false;
|
||||
}
|
||||
return;
|
||||
} else if (isUntrustedTerminalInputPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
autocompleteCloseRef.current?.();
|
||||
} else if (isConfirmedTerminalShellPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = false;
|
||||
@@ -2350,6 +2364,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}), []);
|
||||
|
||||
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
|
||||
const archiveHostRef = useRef(host);
|
||||
archiveHostRef.current = host;
|
||||
const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([]));
|
||||
const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {});
|
||||
|
||||
@@ -4345,6 +4361,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
xTermRuntimeContextRef.current = {
|
||||
host,
|
||||
getHost: () => archiveHostRef.current,
|
||||
fontFamilyId,
|
||||
resolvedFontFamily,
|
||||
fontSize: effectiveFontSize,
|
||||
|
||||
@@ -44,6 +44,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { registerQuickConnectOpener } from "../application/quickConnectBridge";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useStoredNumber } from "../application/state/useStoredNumber";
|
||||
@@ -561,6 +562,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => registerQuickConnectOpener(({ target, warnings }) => {
|
||||
setQuickConnectTarget(target);
|
||||
setQuickConnectWarnings(warnings);
|
||||
setIsQuickConnectOpen(true);
|
||||
}), []);
|
||||
|
||||
// Protocol select state (for hosts with multiple protocols)
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
|
||||
null,
|
||||
@@ -1792,7 +1799,8 @@ export const vaultViewAreEqual = (
|
||||
// Only these terminal connection settings are forwarded to
|
||||
// PortForwarding inside the vault, so compare them directly. Other
|
||||
// terminal settings (fonts, themes, etc.) don't affect this subtree
|
||||
// and we don't want to re-render for them.
|
||||
// and we don't want to re-render for them. onConnect is also omitted:
|
||||
// temporary-connection toggles are read from a ref at connect time.
|
||||
prev.terminalSettings?.verifyHostKeys ===
|
||||
next.terminalSettings?.verifyHostKeys &&
|
||||
prev.terminalSettings?.keepaliveInterval ===
|
||||
|
||||
@@ -47,7 +47,30 @@ test("plugin importer merge and commit workflow is owned by application state",
|
||||
assert.match(pluginImporterCommitHookSource, /buildPluginImporterSafePreview/);
|
||||
});
|
||||
|
||||
test("host list section receives marquee selection and batched group move", () => {
|
||||
const sectionStart = vaultViewLayoutSource.indexOf("<VaultHostListSection");
|
||||
const sectionEnd = vaultViewLayoutSource.indexOf("/>", sectionStart);
|
||||
const section = vaultViewLayoutSource.slice(sectionStart, sectionEnd);
|
||||
assert.match(section, /\bmoveHostsToGroup,/);
|
||||
assert.match(section, /\bsetIsMultiSelectMode,/);
|
||||
assert.match(section, /\bsetSelectedHostIds,/);
|
||||
});
|
||||
|
||||
test("Vault import entry is disabled while an import is running", () => {
|
||||
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
|
||||
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
|
||||
});
|
||||
|
||||
test("new host menu keeps the local shell icon at the same size as its siblings", () => {
|
||||
const menuStart = vaultViewLayoutSource.indexOf('<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>');
|
||||
assert.ok(menuStart >= 0);
|
||||
const menuEnd = vaultViewLayoutSource.indexOf("</DropdownContent>", menuStart);
|
||||
const menu = vaultViewLayoutSource.slice(menuStart, menuEnd);
|
||||
assert.match(menu, /className="flex w-max min-w-44 flex-col"/);
|
||||
assert.doesNotMatch(menu, /className="w-44"/);
|
||||
assert.match(menu, /<TerminalSquare size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<FolderTree size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Upload size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Download size=\{14\} className="shrink-0" \/>/);
|
||||
assert.doesNotMatch(menu, /className="w-full justify-start gap-2"/);
|
||||
});
|
||||
@@ -51,6 +51,7 @@ import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { CodebuddyCard } from "./ai/CodebuddyCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { ExternalMcpCard } from "./ai/ExternalMcpCard";
|
||||
import { AIPrivacySection } from "./ai/AIPrivacySection";
|
||||
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
|
||||
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
|
||||
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
|
||||
@@ -1297,6 +1298,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
||||
<ExternalMcpCard />
|
||||
</SettingsSection>
|
||||
|
||||
@@ -998,6 +998,26 @@ function SettingsTerminalTab(props: {
|
||||
onChange={(v) => updateTerminalSetting("verifyHostKeys", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-temporary-legacy-algorithms"
|
||||
label={t("settings.terminal.connection.temporaryLegacyAlgorithms")}
|
||||
description={t("settings.terminal.connection.temporaryLegacyAlgorithms.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.temporaryConnectionsAllowLegacyAlgorithms}
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsAllowLegacyAlgorithms", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-temporary-network-device-mode"
|
||||
label={t("settings.terminal.connection.temporaryNetworkDeviceMode")}
|
||||
description={t("settings.terminal.connection.temporaryNetworkDeviceMode.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.temporaryConnectionsNetworkDeviceMode}
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsNetworkDeviceMode", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-ssh-auto-reconnect"
|
||||
label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
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 { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
|
||||
import { AIPrivacySection } from './AIPrivacySection';
|
||||
|
||||
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
|
||||
const values = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
window: {
|
||||
netcatty: { externalMcpSetConfig: setConfig },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
},
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
}
|
||||
return {
|
||||
values,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function view() {
|
||||
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
|
||||
}
|
||||
|
||||
test('privacy settings default off and explain their limits and connection scope', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const markup = JSON.stringify(renderer!.toJSON());
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
|
||||
assert.match(markup, /cannot detect every secret/);
|
||||
assert.match(markup, /AI privacy/);
|
||||
assert.match(markup, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(markup, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(markup, /Binary tool responses are blocked/);
|
||||
assert.match(markup, /one managed conversation or external MCP connection/);
|
||||
assert.match(markup, /reconnect external MCP clients/);
|
||||
assert.match(markup, /start a new managed conversation/);
|
||||
assert.match(markup, /Additional IP ranges/);
|
||||
assert.match(markup, /Custom regular expressions/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
|
||||
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
|
||||
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
|
||||
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
|
||||
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
|
||||
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
|
||||
});
|
||||
|
||||
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
|
||||
const calls: unknown[] = [];
|
||||
let accepted = false;
|
||||
const env = installEnvironment(async (config) => {
|
||||
calls.push(config);
|
||||
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
const privacySwitch = () => root.findAllByType('button').find((node) => (
|
||||
node.props['aria-label'] === 'Enable text redaction'
|
||||
))!;
|
||||
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
|
||||
await act(async () => { privacySwitch().props.onClick(); });
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
|
||||
});
|
||||
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
|
||||
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
|
||||
accepted = true;
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
|
||||
});
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, {
|
||||
enabled: true,
|
||||
redactPrivateIps: false,
|
||||
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
});
|
||||
assert.deepEqual(calls[1], { redaction: saved });
|
||||
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
|
||||
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
await act(async () => { renderer = create(view()); });
|
||||
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
|
||||
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('privacy apply disables draft controls until shared validation settles', async () => {
|
||||
let resolveConfig!: (result: unknown) => void;
|
||||
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
|
||||
});
|
||||
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
|
||||
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { resolveConfig({ ok: true }); });
|
||||
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
|
||||
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shared privacy controls refresh when another window changes the shared preference', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
|
||||
await act(async () => { window.dispatchEvent(event); });
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
export const AIPrivacySection: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { settings, error, applying, apply } = useAIPrivacyState();
|
||||
const [enabled, setEnabled] = useState(settings.enabled);
|
||||
const [privateIps, setPrivateIps] = useState(settings.redactPrivateIps);
|
||||
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
|
||||
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
setPatterns(settings.patterns.join("\n"));
|
||||
}, [settings]);
|
||||
|
||||
const onApply = async () => {
|
||||
setApplied(false);
|
||||
const saved = await apply({
|
||||
enabled,
|
||||
redactPrivateIps: privateIps,
|
||||
cidrs: cidrs.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
|
||||
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
|
||||
});
|
||||
setApplied(saved);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow label={t("ai.privacy.enable")}>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={(next) => { setEnabled(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.enable")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.privacy.privateIps")}
|
||||
description={t("ai.privacy.privateIps.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={privateIps}
|
||||
onChange={(next) => { setPrivateIps(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.privateIps")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
|
||||
<textarea
|
||||
value={cidrs}
|
||||
onChange={(event) => { setCidrs(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.cidrs")}
|
||||
placeholder={"192.0.2.0/24\n2001:db8::/32"}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
|
||||
<textarea
|
||||
value={patterns}
|
||||
onChange={(event) => { setPatterns(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.patterns")}
|
||||
placeholder="INTERNAL_[A-Z0-9]+"
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("ai.privacy.failed")} {error}
|
||||
</p>
|
||||
) : applied ? (
|
||||
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
|
||||
type CopyableCodeBlockProps = {
|
||||
label?: string;
|
||||
value: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
emptyLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
label,
|
||||
value,
|
||||
copyKey,
|
||||
copied,
|
||||
onCopy,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
emptyLabel,
|
||||
className,
|
||||
}) => {
|
||||
const display = value || emptyLabel || "";
|
||||
const canCopy = Boolean(value);
|
||||
const isCopied = copied === copyKey;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
) : null}
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canCopy}
|
||||
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onCopy(copyKey, value)}
|
||||
aria-label={isCopied ? copiedLabel : copyLabel}
|
||||
title={isCopied ? copiedLabel : copyLabel}
|
||||
>
|
||||
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { HelpCircle, RefreshCw } from "lucide-react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import {
|
||||
readExternalMcpApprovalAlertMode,
|
||||
@@ -14,7 +14,18 @@ import {
|
||||
writeExternalMcpMode,
|
||||
writeExternalMcpSilentSessions,
|
||||
writeSessionIdleTimeoutMinutes,
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
readExternalMcpHttpClientHost,
|
||||
readExternalMcpHttpEnabled,
|
||||
readExternalMcpHttpListenAddress,
|
||||
readExternalMcpHttpPort,
|
||||
writeExternalMcpHttpAllowCustomBind,
|
||||
writeExternalMcpHttpClientHost,
|
||||
writeExternalMcpHttpEnabled,
|
||||
writeExternalMcpHttpListenAddress,
|
||||
writeExternalMcpHttpPort,
|
||||
type ExternalMcpApprovalAlertMode,
|
||||
type ExternalMcpHttpSettings,
|
||||
type ExternalMcpMode,
|
||||
useExternalMcpToggleState,
|
||||
} from "../../../../application/state/useExternalMcpToggleState";
|
||||
@@ -22,70 +33,14 @@ import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
|
||||
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
|
||||
import { getBridge } from "./types";
|
||||
|
||||
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor" | "other";
|
||||
|
||||
const CLIENT_TABS: ExternalMcpClient[] = ["codex", "claude", "grok", "cursor", "other"];
|
||||
|
||||
type CopyableCodeBlockProps = {
|
||||
label?: string;
|
||||
value: string;
|
||||
copyKey: string;
|
||||
copied: string | null;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
copyLabel: string;
|
||||
copiedLabel: string;
|
||||
emptyLabel?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
|
||||
label,
|
||||
value,
|
||||
copyKey,
|
||||
copied,
|
||||
onCopy,
|
||||
copyLabel,
|
||||
copiedLabel,
|
||||
emptyLabel,
|
||||
className,
|
||||
}) => {
|
||||
const display = value || emptyLabel || "";
|
||||
const canCopy = Boolean(value);
|
||||
const isCopied = copied === copyKey;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1.5", className)}>
|
||||
{label ? (
|
||||
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
||||
) : null}
|
||||
<div className="group relative rounded-md border border-border/60 bg-muted/20">
|
||||
<pre
|
||||
className={cn(
|
||||
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{display}
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!canCopy}
|
||||
className="absolute right-1.5 top-1.5 h-7 w-7 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void onCopy(copyKey, value)}
|
||||
aria-label={isCopied ? copiedLabel : copyLabel}
|
||||
title={isCopied ? copiedLabel : copyLabel}
|
||||
>
|
||||
{isCopied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ExternalMcpStatus = {
|
||||
ok: boolean;
|
||||
enabled?: boolean;
|
||||
@@ -100,6 +55,7 @@ type ExternalMcpStatus = {
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
permissionMode?: string;
|
||||
error?: string | null;
|
||||
http?: ExternalMcpHttpViewStatus | null;
|
||||
};
|
||||
|
||||
type ClientSetupStatus = {
|
||||
@@ -296,7 +252,7 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
|
||||
|
||||
export const ExternalMcpCard: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { enabled, setEnabled } = useExternalMcpToggleState();
|
||||
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
|
||||
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
|
||||
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
|
||||
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
|
||||
@@ -305,6 +261,14 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
);
|
||||
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
|
||||
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions());
|
||||
const [httpSettings, setHttpSettings] = useState<ExternalMcpHttpSettings>(() => ({
|
||||
enabled: readExternalMcpHttpEnabled(),
|
||||
port: readExternalMcpHttpPort(),
|
||||
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
|
||||
listenAddress: readExternalMcpHttpListenAddress(),
|
||||
clientHost: readExternalMcpHttpClientHost(),
|
||||
}));
|
||||
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
|
||||
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
|
||||
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
|
||||
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
|
||||
@@ -359,6 +323,42 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
writeExternalMcpSilentSessions(nextSilentSessions);
|
||||
}, []);
|
||||
|
||||
const applyHttpSettings = useCallback(async (next: ExternalMcpHttpSettings) => {
|
||||
const normalized: ExternalMcpHttpSettings = {
|
||||
enabled: writeExternalMcpHttpEnabled(next.enabled),
|
||||
port: writeExternalMcpHttpPort(next.port),
|
||||
allowCustomBind: writeExternalMcpHttpAllowCustomBind(next.allowCustomBind),
|
||||
listenAddress: writeExternalMcpHttpListenAddress(next.listenAddress),
|
||||
clientHost: writeExternalMcpHttpClientHost(next.clientHost),
|
||||
};
|
||||
setHttpSettings(normalized);
|
||||
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
|
||||
httpEnabled: normalized.enabled,
|
||||
httpPort: normalized.port,
|
||||
httpAllowCustomBind: normalized.allowCustomBind,
|
||||
httpListenAddress: normalized.listenAddress,
|
||||
httpClientHost: normalized.clientHost,
|
||||
});
|
||||
if (nextStatus && typeof nextStatus === "object") {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const rotateHttpToken = useCallback(async () => {
|
||||
const bridge = getBridge();
|
||||
if (!bridge?.externalMcpSetConfig) return;
|
||||
setIsRotatingHttpToken(true);
|
||||
setActionMessage(null);
|
||||
try {
|
||||
const nextStatus = await bridge.externalMcpSetConfig({ rotateHttpToken: true });
|
||||
if (nextStatus && typeof nextStatus === "object") {
|
||||
setStatus(nextStatus as ExternalMcpStatus);
|
||||
}
|
||||
} finally {
|
||||
setIsRotatingHttpToken(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
|
||||
const bridge = getBridge();
|
||||
const includeClients = options?.clients !== false;
|
||||
@@ -681,8 +681,7 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
checked={enabled}
|
||||
onChange={(nextEnabled) => {
|
||||
setActionMessage(null);
|
||||
setEnabled(nextEnabled);
|
||||
window.setTimeout(() => { void refreshStatus(); }, 0);
|
||||
void setEnabled(nextEnabled).then(() => refreshStatus());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -806,6 +805,17 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
|
||||
<ExternalMcpHttpSection
|
||||
externalMcpEnabled={enabled}
|
||||
settings={httpSettings}
|
||||
status={status?.http || null}
|
||||
copied={copied}
|
||||
rotating={isRotatingHttpToken}
|
||||
onCopy={copyText}
|
||||
onChange={(next) => { void applyHttpSettings(next); }}
|
||||
onRotateToken={() => { void rotateHttpToken(); }}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex min-h-8 items-center justify-between gap-2">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
|
||||
@@ -978,8 +988,8 @@ export const ExternalMcpCard: React.FC = () => {
|
||||
{actionMessage.text}
|
||||
</div>
|
||||
) : null}
|
||||
{status?.error ? (
|
||||
<div className="text-xs text-destructive">{status.error}</div>
|
||||
{configError || status?.error ? (
|
||||
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import React, { useState } from "react";
|
||||
import {
|
||||
buildClaudeHttpAddCommand,
|
||||
buildCodexHttpEnvLine,
|
||||
buildCodexHttpToml,
|
||||
buildExternalMcpHttpUrl,
|
||||
buildGenericHttpMcpJson,
|
||||
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
} from "../../../../domain/externalMcpHttp";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { cn } from "../../../../lib/utils";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
|
||||
import { CopyableCodeBlock } from "./CopyableCodeBlock";
|
||||
|
||||
export type ExternalMcpHttpViewStatus = {
|
||||
enabled?: boolean;
|
||||
listening?: boolean;
|
||||
port?: number;
|
||||
listenAddress?: string;
|
||||
allowCustomBind?: boolean;
|
||||
clientHost?: string;
|
||||
url?: string | null;
|
||||
bearerToken?: string | null;
|
||||
state?: string;
|
||||
error?: string | null;
|
||||
errorCode?: string | null;
|
||||
};
|
||||
|
||||
export type ExternalMcpHttpViewSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
allowCustomBind: boolean;
|
||||
listenAddress: string;
|
||||
clientHost: string;
|
||||
};
|
||||
|
||||
type ExternalMcpHttpSectionProps = {
|
||||
externalMcpEnabled: boolean;
|
||||
settings: ExternalMcpHttpViewSettings;
|
||||
status: ExternalMcpHttpViewStatus | null;
|
||||
copied: string | null;
|
||||
rotating: boolean;
|
||||
onCopy: (key: string, text: string) => void;
|
||||
onChange: (next: ExternalMcpHttpViewSettings) => void;
|
||||
onRotateToken: () => void;
|
||||
};
|
||||
|
||||
function isWildcardAddress(value: string): boolean {
|
||||
const address = value.trim();
|
||||
return address === "0.0.0.0" || address === "::";
|
||||
}
|
||||
|
||||
function isIpAddress(value: string): boolean {
|
||||
const address = value.trim();
|
||||
const ipv4 = address.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||
if (ipv4) {
|
||||
return ipv4.slice(1).every((part) => Number(part) <= 255 && String(Number(part)) === part);
|
||||
}
|
||||
return address.includes(":") && /^[0-9a-fA-F:]+$/.test(address);
|
||||
}
|
||||
|
||||
function parsePort(value: string): number | null {
|
||||
if (!/^\d+$/.test(value.trim())) return null;
|
||||
const port = Number(value);
|
||||
if (!Number.isInteger(port) || port < 1024 || port > 65535) return null;
|
||||
return port;
|
||||
}
|
||||
|
||||
export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
|
||||
externalMcpEnabled,
|
||||
settings,
|
||||
status,
|
||||
copied,
|
||||
rotating,
|
||||
onCopy,
|
||||
onChange,
|
||||
onRotateToken,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
|
||||
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
|
||||
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
|
||||
const [portError, setPortError] = useState(false);
|
||||
const [addressError, setAddressError] = useState(false);
|
||||
const [clientHostError, setClientHostError] = useState(false);
|
||||
|
||||
const listenAddress = settings.allowCustomBind
|
||||
? (status?.listenAddress || settings.listenAddress)
|
||||
: "127.0.0.1";
|
||||
const advertisedHost = status?.clientHost ?? settings.clientHost;
|
||||
const url = status
|
||||
? (status.url || "")
|
||||
: buildExternalMcpHttpUrl(listenAddress, settings.port, advertisedHost);
|
||||
const showClientHost = settings.allowCustomBind && (
|
||||
isWildcardAddress(settings.listenAddress) || isWildcardAddress(addressDraft)
|
||||
);
|
||||
const token = status?.bearerToken || "";
|
||||
const listening = Boolean(status?.listening);
|
||||
const statusLabel = listening
|
||||
? t("ai.externalMcp.http.listening")
|
||||
: t("ai.externalMcp.http.notListening");
|
||||
const statusClass = status?.errorCode
|
||||
? "text-destructive"
|
||||
: listening
|
||||
? "text-emerald-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
let errorText = "";
|
||||
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
|
||||
else if (addressError) errorText = t("ai.externalMcp.http.invalidAddress");
|
||||
else if (clientHostError) errorText = t("ai.externalMcp.http.invalidClientHost");
|
||||
else if (status?.errorCode === "client_host_required") errorText = t("ai.externalMcp.http.clientHostRequired");
|
||||
else if (status?.errorCode === "port_in_use") {
|
||||
errorText = t("ai.externalMcp.http.portInUse", { port: String(status.port ?? settings.port) });
|
||||
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
|
||||
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
|
||||
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
|
||||
|
||||
const commitPort = () => {
|
||||
const port = parsePort(portDraft);
|
||||
if (port == null) {
|
||||
setPortError(true);
|
||||
return;
|
||||
}
|
||||
setPortError(false);
|
||||
setPortDraft(String(port));
|
||||
if (port !== settings.port) onChange({ ...settings, port });
|
||||
};
|
||||
|
||||
const commitClientHost = () => {
|
||||
const clientHost = clientHostDraft.trim();
|
||||
if (clientHost && (!isIpAddress(clientHost) || isWildcardAddress(clientHost))) {
|
||||
setClientHostError(true);
|
||||
return;
|
||||
}
|
||||
setClientHostError(false);
|
||||
setClientHostDraft(clientHost);
|
||||
if (clientHost !== settings.clientHost) onChange({ ...settings, clientHost });
|
||||
};
|
||||
|
||||
const commitAddress = () => {
|
||||
const address = addressDraft.trim();
|
||||
if (!isIpAddress(address)) {
|
||||
setAddressError(true);
|
||||
return;
|
||||
}
|
||||
setAddressError(false);
|
||||
setAddressDraft(address);
|
||||
if (address !== settings.listenAddress) onChange({ ...settings, listenAddress: address });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
|
||||
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow
|
||||
anchorId="ai-external-mcp-http"
|
||||
label={t("ai.externalMcp.http.enable")}
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.enabled}
|
||||
onChange={(enabled) => onChange({ ...settings, enabled })}
|
||||
ariaLabel={t("ai.externalMcp.http.enable")}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.http.port")}
|
||||
description={t("ai.externalMcp.http.portHint")}
|
||||
>
|
||||
<input
|
||||
type="number"
|
||||
aria-label={t("ai.externalMcp.http.port")}
|
||||
min={1024}
|
||||
max={65535}
|
||||
value={portDraft}
|
||||
onChange={(event) => {
|
||||
setPortDraft(event.currentTarget.value);
|
||||
setPortError(false);
|
||||
}}
|
||||
onBlur={commitPort}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className="w-24 rounded-md border border-border/60 bg-background px-2 py-1 text-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.http.allowCustomBind")}
|
||||
description={t("ai.externalMcp.http.allowCustomBind.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={settings.allowCustomBind}
|
||||
onChange={(allowCustomBind) => onChange({ ...settings, allowCustomBind })}
|
||||
ariaLabel={t("ai.externalMcp.http.allowCustomBind")}
|
||||
/>
|
||||
</SettingRow>
|
||||
{settings.allowCustomBind ? (
|
||||
<SettingRow label={t("ai.externalMcp.http.listenAddress")}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.listenAddress")}
|
||||
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
|
||||
value={addressDraft}
|
||||
onChange={(event) => {
|
||||
setAddressDraft(event.currentTarget.value);
|
||||
setAddressError(false);
|
||||
}}
|
||||
onBlur={commitAddress}
|
||||
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
{showClientHost ? (
|
||||
<SettingRow
|
||||
label={t("ai.externalMcp.http.clientHost")}
|
||||
description={t("ai.externalMcp.http.clientHost.description")}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
aria-label={t("ai.externalMcp.http.clientHost")}
|
||||
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
|
||||
value={clientHostDraft}
|
||||
onChange={(event) => {
|
||||
setClientHostDraft(event.currentTarget.value);
|
||||
setClientHostError(false);
|
||||
}}
|
||||
onBlur={commitClientHost}
|
||||
className="w-36 rounded-md border border-border/60 bg-background px-2 py-1 font-mono text-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
</SettingCard>
|
||||
{settings.allowCustomBind ? (
|
||||
<p className="text-xs text-amber-500 leading-5">{t("ai.externalMcp.http.warning")}</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.wslHint")}</p>
|
||||
{settings.enabled && !externalMcpEnabled ? (
|
||||
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
|
||||
) : null}
|
||||
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
|
||||
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.url")}
|
||||
value={url}
|
||||
copyKey="http-url"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.token")}
|
||||
value={token}
|
||||
copyKey="http-token"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.regenerateHint")}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!externalMcpEnabled || !settings.enabled || rotating}
|
||||
onClick={onRotateToken}
|
||||
>
|
||||
{t("ai.externalMcp.http.regenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.codexSnippet")}
|
||||
value={buildCodexHttpToml(url)}
|
||||
copyKey="http-codex"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.envLine")}
|
||||
value={token ? buildCodexHttpEnvLine(token) : ""}
|
||||
copyKey="http-env"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.claudeCommand")}
|
||||
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
|
||||
copyKey="http-claude"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
<CopyableCodeBlock
|
||||
label={t("ai.externalMcp.http.genericSnippet")}
|
||||
value={token ? buildGenericHttpMcpJson(url, token) : ""}
|
||||
copyKey="http-json"
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
copyLabel={t("ai.externalMcp.copy")}
|
||||
copiedLabel={t("ai.externalMcp.copied")}
|
||||
emptyLabel={t("ai.externalMcp.unavailable")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -177,6 +177,13 @@ export interface NetcattyAiBridge {
|
||||
mode?: 'temporary' | 'persistent';
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
redaction?: import("../../../../application/state/useExternalMcpToggleState").ExternalMcpRedactionSettings;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
httpListenAddress?: string;
|
||||
httpClientHost?: string;
|
||||
rotateHttpToken?: boolean;
|
||||
}) => Promise<Record<string, unknown>>;
|
||||
externalMcpGetUniversalSetupPrompt?: () => Promise<{
|
||||
ok: boolean;
|
||||
|
||||
@@ -7,7 +7,8 @@ import type { TerminalSession } from '../../types';
|
||||
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
|
||||
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
|
||||
import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { buildContainerExecShellCommand, buildContainerLogsCommand, describeRuntimeFailure } from '../../domain/systemManager/containerRuntime';
|
||||
import { DockerContainerDetail } from './DockerContainerDetail';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
import { useStableListOrder, mergePollListByKey } from './listStable';
|
||||
@@ -59,6 +60,7 @@ async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon
|
||||
interface DockerContainersPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -157,6 +159,7 @@ const DockerContainerRow = memo(function DockerContainerRow({
|
||||
export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -179,15 +182,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
setConfirmAction(null);
|
||||
setPendingAction(null);
|
||||
setSelectedId(null);
|
||||
}, [sessionId]);
|
||||
}, [runtime, sessionId]);
|
||||
|
||||
const containersFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerContainers(sessionId);
|
||||
const result = await backend.listDockerContainers(sessionId, runtime);
|
||||
if (!result.success || !result.containers) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDocker'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDocker')));
|
||||
}
|
||||
return result.containers;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
|
||||
@@ -195,7 +198,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
);
|
||||
|
||||
const matched = useMemo<DockerContainerInfo[]>(() => {
|
||||
@@ -240,12 +243,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
);
|
||||
const statsFetcher = useCallback(async () => {
|
||||
if (statContainerIds.length === 0) return [];
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
|
||||
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds, runtime });
|
||||
if (!result.success || !result.stats) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerStats')));
|
||||
}
|
||||
return result.stats;
|
||||
}, [backend, sessionId, stableT, statContainerIds]);
|
||||
}, [backend, runtime, sessionId, stableT, statContainerIds]);
|
||||
|
||||
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
|
||||
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
|
||||
@@ -266,18 +269,19 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}, [stats]);
|
||||
|
||||
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
|
||||
`${sessionId}:${container.id}`
|
||||
), [sessionId]);
|
||||
`${sessionId}:${runtime}:${container.id}`
|
||||
), [runtime, sessionId]);
|
||||
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
|
||||
const result = await backend.dockerInspect({
|
||||
sessionId,
|
||||
containerId: container.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByContainerId,
|
||||
loadRecord: loadContainerInspect,
|
||||
@@ -305,9 +309,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
}
|
||||
setPendingAction({ id: containerId, action });
|
||||
try {
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName });
|
||||
const result = await backend.dockerAction({ sessionId, containerId, action, newName, runtime });
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const affectedContainer = (containers ?? []).find((container) => (
|
||||
@@ -332,6 +339,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
invalidateContainerInspectMatching,
|
||||
refresh,
|
||||
refreshContainerInspect,
|
||||
runtime,
|
||||
sessionId,
|
||||
t,
|
||||
]);
|
||||
@@ -360,7 +368,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`docker: ${container.name || id}`,
|
||||
buildDockerExecShellCommand(id),
|
||||
buildContainerExecShellCommand(runtime, id, parentSession.shellType),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -372,7 +380,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
|
||||
const openLogs = useCallback(async (container: DockerContainerInfo) => {
|
||||
const id = container.id.slice(0, 12);
|
||||
@@ -387,7 +395,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
backend,
|
||||
parentSession,
|
||||
`logs: ${container.name || id}`,
|
||||
buildDockerLogsCommand(id),
|
||||
buildContainerLogsCommand(runtime, id, parentSession.shellType),
|
||||
{ icon: await buildContainerPopupIcon(container.image) },
|
||||
);
|
||||
if (!result.success) {
|
||||
@@ -399,7 +407,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
|
||||
});
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, sessionId, t]);
|
||||
}, [backend, parentSession, runtime, sessionId, t]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Layers, Loader2, Tag, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { describeRuntimeFailure, type ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import { dockerImageRowKey, type DockerImageInfo } from '../../domain/systemManager/types';
|
||||
import { dockerImageInfoEqual } from '../../domain/systemManager/pollEquals';
|
||||
import { DockerImageIcon } from './DockerImageIcon';
|
||||
@@ -35,6 +36,7 @@ type PendingImageConfirm =
|
||||
|
||||
interface DockerImagesPanelProps {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -89,6 +91,7 @@ const DockerImageRow = memo(function DockerImageRow({
|
||||
|
||||
export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
sessionId,
|
||||
runtime = 'docker',
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -111,15 +114,15 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
// Clear busy so a hung/in-flight action from the previous session cannot
|
||||
// leave the new session's confirm dialog permanently disabled.
|
||||
setActionBusy(false);
|
||||
}, [sessionId]);
|
||||
}, [runtime, sessionId]);
|
||||
|
||||
const imagesFetcher = useCallback(async () => {
|
||||
const result = await backend.listDockerImages(sessionId);
|
||||
const result = await backend.listDockerImages(sessionId, runtime);
|
||||
if (!result.success || !result.images) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.loadDockerImages'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerImages')));
|
||||
}
|
||||
return result.images;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: images, error, loading, refresh } = usePolling<DockerImageInfo[]>(
|
||||
@@ -127,7 +130,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
listIntervalMs,
|
||||
isVisible || warmupEnabled,
|
||||
(prev, next) => mergePollListByKey(prev, next, dockerImageRowKey, dockerImageInfoEqual),
|
||||
{ poll: isVisible, resetKey: sessionId },
|
||||
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` },
|
||||
);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -154,18 +157,19 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const displayList = useStableListOrder(filtered, dockerImageRowKey, query, compareImages);
|
||||
|
||||
const getImageInspectKey = useCallback((image: DockerImageInfo) => (
|
||||
`${sessionId}:${dockerImageRowKey(image)}`
|
||||
), [sessionId]);
|
||||
`${sessionId}:${runtime}:${dockerImageRowKey(image)}`
|
||||
), [runtime, sessionId]);
|
||||
const fetchImageInspect = useCallback(async (image: DockerImageInfo) => {
|
||||
const result = await backend.dockerImageInspect({
|
||||
sessionId,
|
||||
imageId: image.id.slice(0, 12),
|
||||
runtime,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
|
||||
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed')));
|
||||
}
|
||||
return result.inspect ?? null;
|
||||
}, [backend, sessionId, stableT]);
|
||||
}, [backend, runtime, sessionId, stableT]);
|
||||
const {
|
||||
records: inspectByImageKey,
|
||||
loadRecord: loadImageInspect,
|
||||
@@ -184,15 +188,26 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const tagged = Boolean(
|
||||
image.repository
|
||||
&& image.tag
|
||||
&& image.repository !== '<none>'
|
||||
&& image.tag !== '<none>',
|
||||
);
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'rm',
|
||||
imageId: image.id.slice(0, 12),
|
||||
force: image.tag === '<none>',
|
||||
force: !tagged,
|
||||
...(tagged ? { repository: image.repository, tag: image.tag } : {}),
|
||||
});
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedId === dockerImageRowKey(image)) {
|
||||
@@ -205,7 +220,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, selectedId, sessionId, t]);
|
||||
}, [backend, getImageInspectKey, invalidateImageInspect, refresh, runtime, selectedId, sessionId, t]);
|
||||
|
||||
const handleRemove = useCallback((image: DockerImageInfo) => {
|
||||
const label = image.name || image.id.slice(0, 12);
|
||||
@@ -216,10 +231,13 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const actionGeneration = actionGenerationRef.current;
|
||||
setActionBusy(true);
|
||||
try {
|
||||
const result = await backend.dockerImageAction({ sessionId, action: 'prune', all });
|
||||
const result = await backend.dockerImageAction({ sessionId, runtime, action: 'prune', all });
|
||||
if (actionGenerationRef.current !== actionGeneration) return;
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
@@ -228,7 +246,7 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
setActionBusy(false);
|
||||
}
|
||||
}
|
||||
}, [backend, refresh, sessionId, t]);
|
||||
}, [backend, refresh, runtime, sessionId, t]);
|
||||
|
||||
const handlePrune = useCallback((all: boolean) => {
|
||||
setConfirmTarget({ kind: 'prune', all });
|
||||
@@ -237,13 +255,17 @@ export const DockerImagesPanel = memo(function DockerImagesPanel({
|
||||
const handleTagSubmit = async (image: DockerImageInfo, repository: string, tag: string) => {
|
||||
const result = await backend.dockerImageAction({
|
||||
sessionId,
|
||||
runtime,
|
||||
action: 'tag',
|
||||
imageId: image.id.slice(0, 12),
|
||||
repository,
|
||||
tag: tag || 'latest',
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
|
||||
@@ -1,19 +1,32 @@
|
||||
import { Box, Layers } from 'lucide-react';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { useContainerRuntimeSelection } from '../../application/state/systemManager/useContainerRuntimeSelection';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import type { ContainerSourceId } from '../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../domain/systemManager/types';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import { DockerContainersPanel } from './DockerContainersPanel';
|
||||
import { DockerImagesPanel } from './DockerImagesPanel';
|
||||
import { KubernetesPodsPanel } from './KubernetesPodsPanel';
|
||||
import { SystemPanelShell } from './SystemPanelUi';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
type DockerSubTab = 'containers' | 'images';
|
||||
|
||||
const RUNTIME_LABEL: Record<ContainerSourceId, string> = {
|
||||
docker: 'systemManager.runtime.docker',
|
||||
podman: 'systemManager.runtime.podman',
|
||||
nerdctl: 'systemManager.runtime.nerdctl',
|
||||
kubernetes: 'systemManager.runtime.kubernetes',
|
||||
};
|
||||
|
||||
interface DockerManagerTabProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
capabilities?: SessionCapabilities;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
@@ -21,9 +34,48 @@ interface DockerManagerTabProps {
|
||||
statsRefreshIntervalSec: number;
|
||||
}
|
||||
|
||||
function CompactSelect({
|
||||
value,
|
||||
options,
|
||||
ariaLabel,
|
||||
section,
|
||||
onChange,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
options: { id: string; label: string }[];
|
||||
ariaLabel: string;
|
||||
section: string;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
aria-label={ariaLabel}
|
||||
data-section={section}
|
||||
className={cn(
|
||||
"h-6 w-auto max-w-[9.5rem] gap-1 rounded border-border/40 bg-transparent px-2 py-0 text-[11px] shadow-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.id} value={option.id} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
sessionId,
|
||||
parentSession,
|
||||
capabilities,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
@@ -32,6 +84,26 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
}: DockerManagerTabProps) {
|
||||
const { t } = useI18n();
|
||||
const [subTab, setSubTab] = useState<DockerSubTab>('containers');
|
||||
const {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
} = useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey: parentSession.hostId || sessionId,
|
||||
capabilities,
|
||||
backend,
|
||||
});
|
||||
|
||||
const tabs: { id: DockerSubTab; icon: typeof Box; label: string }[] = [
|
||||
{ id: 'containers', icon: Box, label: t('systemManager.docker.subTabs.containers') },
|
||||
@@ -40,8 +112,8 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
|
||||
return (
|
||||
<SystemPanelShell section="system-manager-docker">
|
||||
<div className="shrink-0 flex items-center gap-0.5 px-2 py-1 border-b border-border/30">
|
||||
{tabs.map(({ id, icon: Icon, label }) => (
|
||||
<div className="shrink-0 flex items-center gap-1 px-2 py-1 border-b border-border/30">
|
||||
{source !== 'kubernetes' && tabs.map(({ id, icon: Icon, label }) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
@@ -57,29 +129,79 @@ export const DockerManagerTab = memo(function DockerManagerTab({
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{showRuntimeSwitcher && source && detected.includes(source) && (
|
||||
<CompactSelect
|
||||
value={source}
|
||||
ariaLabel={t('systemManager.runtime.switch')}
|
||||
section="container-runtime-switcher"
|
||||
className="shrink-0"
|
||||
options={detected.map((id) => ({ id, label: t(RUNTIME_LABEL[id]) }))}
|
||||
onChange={selectSource}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && contextName && contexts.some((item) => item.name === contextName) && (
|
||||
<CompactSelect
|
||||
value={contextName}
|
||||
ariaLabel={t('systemManager.k8s.context')}
|
||||
section="k8s-context-select"
|
||||
className="w-full min-w-0 max-w-none flex-1"
|
||||
options={contexts.map((item) => ({ id: item.name, label: item.name }))}
|
||||
onChange={selectContext}
|
||||
/>
|
||||
)}
|
||||
{source === 'kubernetes' && namespaceName && namespaces.includes(namespaceName) && (
|
||||
<CompactSelect
|
||||
value={namespaceName}
|
||||
ariaLabel={t('systemManager.k8s.namespace')}
|
||||
section="k8s-namespace-select"
|
||||
className="shrink-0"
|
||||
options={namespaces.map((name) => ({ id: name, label: name }))}
|
||||
onChange={setNamespaceName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
{source && source !== 'kubernetes' && (
|
||||
<>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'containers' && 'hidden')}>
|
||||
<DockerContainersPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
runtime={runtime}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{source === 'kubernetes' && (
|
||||
<KubernetesPodsPanel
|
||||
sessionId={sessionId}
|
||||
parentSession={parentSession}
|
||||
isVisible={isVisible && subTab === 'containers'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'containers')}
|
||||
contextName={contextName}
|
||||
namespaceName={namespaceName}
|
||||
scopeError={scopeError}
|
||||
isVisible={isVisible}
|
||||
warmupEnabled={warmupEnabled}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
statsRefreshIntervalSec={statsRefreshIntervalSec}
|
||||
onReloadScopes={reloadScopes}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', subTab !== 'images' && 'hidden')}>
|
||||
<DockerImagesPanel
|
||||
sessionId={sessionId}
|
||||
isVisible={isVisible && subTab === 'images'}
|
||||
warmupEnabled={warmupEnabled || (isVisible && subTab !== 'images')}
|
||||
backend={backend}
|
||||
listRefreshIntervalSec={listRefreshIntervalSec}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SystemPanelShell>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import { Box, FileText, Info, Terminal, Trash2 } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import type { useSystemManagerBackend } from '../../application/state/useSystemManagerBackend';
|
||||
import { usePolling, useStableTranslate } from '../../application/state/useSystemManager';
|
||||
import type { KubectlPodInfo } from '../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
buildKubectlDescribeCommand,
|
||||
buildKubectlExecShellCommand,
|
||||
buildKubectlLogsCommand,
|
||||
describeRuntimeFailure,
|
||||
podStatusMessageKey,
|
||||
} from '../../domain/systemManager/containerRuntime';
|
||||
import type { TerminalSession } from '../../types';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { useStableListOrder } from './listStable';
|
||||
import { openInteractiveTerminal } from './openInteractiveTerminal';
|
||||
import { SystemPanelConfirmDialog } from './SystemPanelConfirmDialog';
|
||||
import {
|
||||
SystemPanelEmpty,
|
||||
SystemPanelError,
|
||||
SystemPanelList,
|
||||
SystemPanelLoading,
|
||||
SystemPanelMetaBar,
|
||||
SystemPanelRefreshButton,
|
||||
SystemPanelRoundButton,
|
||||
SystemPanelRow,
|
||||
SystemPanelSearch,
|
||||
SystemPanelStatusBadge,
|
||||
SystemPanelToolbar,
|
||||
} from './SystemPanelUi';
|
||||
import { showSystemManagerError } from './systemManagerToast';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface KubernetesPodsPanelProps {
|
||||
sessionId: string;
|
||||
parentSession: TerminalSession;
|
||||
contextName: string | null;
|
||||
namespaceName: string | null;
|
||||
scopeError?: string | null;
|
||||
isVisible: boolean;
|
||||
warmupEnabled?: boolean;
|
||||
backend: Backend;
|
||||
listRefreshIntervalSec: number;
|
||||
onReloadScopes: () => void;
|
||||
}
|
||||
|
||||
function podTone(status: string): 'success' | 'warning' | 'muted' {
|
||||
if (status === 'Running') return 'success';
|
||||
if (status === 'Pending' || status === 'Completed' || status === 'Succeeded') return 'muted';
|
||||
return 'warning';
|
||||
}
|
||||
|
||||
const KubernetesPodRow = memo(function KubernetesPodRow({
|
||||
pod,
|
||||
selected,
|
||||
pendingDelete,
|
||||
onSelect,
|
||||
onShell,
|
||||
onLogs,
|
||||
onDescribe,
|
||||
onDelete,
|
||||
}: {
|
||||
pod: KubectlPodInfo;
|
||||
selected: boolean;
|
||||
pendingDelete: boolean;
|
||||
onSelect: (pod: KubectlPodInfo) => void;
|
||||
onShell: (pod: KubectlPodInfo) => void;
|
||||
onLogs: (pod: KubectlPodInfo) => void;
|
||||
onDescribe: (pod: KubectlPodInfo) => void;
|
||||
onDelete: (pod: KubectlPodInfo) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = statusKey ? t(statusKey) : pod.status;
|
||||
return (
|
||||
<SystemPanelRow
|
||||
selected={selected}
|
||||
onClick={() => onSelect(pod)}
|
||||
leading={<Box size={14} className="text-muted-foreground" />}
|
||||
title={pod.name}
|
||||
subtitle={`${pod.containers.join(', ') || '—'} · ${t('systemManager.k8s.restarts', { count: String(pod.restarts) })}${pod.age ? ` · ${pod.age}` : ''}`}
|
||||
trailing={(
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<SystemPanelStatusBadge tone={podTone(pod.status)}>{statusLabel}</SystemPanelStatusBadge>
|
||||
{pod.shellContainers.length > 0 && (
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.shell')} onClick={() => onShell(pod)}>
|
||||
<Terminal size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
)}
|
||||
<SystemPanelRoundButton title={t('systemManager.docker.logs')} onClick={() => onLogs(pod)}>
|
||||
<FileText size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton title={t('systemManager.k8s.describe')} onClick={() => onDescribe(pod)}>
|
||||
<Info size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
<SystemPanelRoundButton
|
||||
title={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
loading={pendingDelete}
|
||||
onClick={() => onDelete(pod)}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</SystemPanelRoundButton>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const KubernetesPodsPanel = memo(function KubernetesPodsPanel({
|
||||
sessionId,
|
||||
parentSession,
|
||||
contextName,
|
||||
namespaceName,
|
||||
scopeError = null,
|
||||
isVisible,
|
||||
warmupEnabled = false,
|
||||
backend,
|
||||
listRefreshIntervalSec,
|
||||
onReloadScopes,
|
||||
}: KubernetesPodsPanelProps) {
|
||||
const { t } = useI18n();
|
||||
const stableT = useStableTranslate();
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedName, setSelectedName] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<string | null>(null);
|
||||
const [confirmPod, setConfirmPod] = useState<string | null>(null);
|
||||
const [containerPrompt, setContainerPrompt] = useState<{
|
||||
pod: KubectlPodInfo;
|
||||
action: 'shell' | 'logs';
|
||||
} | null>(null);
|
||||
|
||||
const scopeKey = `${sessionId}:${contextName ?? ''}:${namespaceName ?? ''}`;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedName(null);
|
||||
setPendingDelete(null);
|
||||
setConfirmPod(null);
|
||||
setContainerPrompt(null);
|
||||
}, [scopeKey]);
|
||||
|
||||
const podsFetcher = useCallback(async () => {
|
||||
if (!contextName || !namespaceName) return [];
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'pods',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
});
|
||||
if (!result.success || !result.pods) {
|
||||
throw new Error(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadPods'),
|
||||
contextName,
|
||||
));
|
||||
}
|
||||
return result.pods;
|
||||
}, [backend, contextName, namespaceName, sessionId, stableT]);
|
||||
|
||||
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
|
||||
const { data: pods, error, loading, refresh } = usePolling<KubectlPodInfo[]>(
|
||||
podsFetcher,
|
||||
listIntervalMs,
|
||||
(isVisible || warmupEnabled) && !!contextName && !!namespaceName,
|
||||
undefined,
|
||||
{ poll: isVisible, resetKey: scopeKey },
|
||||
);
|
||||
|
||||
const matched = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return (pods ?? []).filter((pod) => {
|
||||
if (!q) return true;
|
||||
const statusKey = podStatusMessageKey(pod.status);
|
||||
const statusLabel = (statusKey ? t(statusKey) : pod.status).toLowerCase();
|
||||
return pod.name.toLowerCase().includes(q)
|
||||
|| pod.status.toLowerCase().includes(q)
|
||||
|| statusLabel.includes(q)
|
||||
|| pod.containers.some((name) => name.toLowerCase().includes(q));
|
||||
});
|
||||
}, [pods, query, t]);
|
||||
|
||||
const displayList = useStableListOrder<KubectlPodInfo, string>(
|
||||
matched,
|
||||
(pod) => pod.name,
|
||||
`${scopeKey}|${query}`,
|
||||
(a, b) => a.name.localeCompare(b.name),
|
||||
);
|
||||
|
||||
const openCommand = useCallback(async (title: string, command: string) => {
|
||||
const result = await openInteractiveTerminal(backend, parentSession, title, command);
|
||||
if (!result.success) {
|
||||
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
|
||||
}
|
||||
}, [backend, parentSession, t]);
|
||||
|
||||
const runPodCommand = useCallback((pod: KubectlPodInfo, action: 'shell' | 'logs' | 'describe', container?: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
if (action === 'describe') {
|
||||
void openCommand(
|
||||
`describe: ${pod.name}`,
|
||||
buildKubectlDescribeCommand(contextName, namespaceName, pod.name, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const containers = action === 'shell' ? pod.shellContainers : pod.containers;
|
||||
if (action === 'shell' && containers.length === 0) return;
|
||||
if (!container && containers.length > 1) {
|
||||
setContainerPrompt({ pod, action });
|
||||
return;
|
||||
}
|
||||
const chosen = container || containers[0];
|
||||
if (action === 'shell') {
|
||||
void openCommand(
|
||||
`pod: ${pod.name}`,
|
||||
buildKubectlExecShellCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
return;
|
||||
}
|
||||
void openCommand(
|
||||
`logs: ${pod.name}`,
|
||||
buildKubectlLogsCommand(contextName, namespaceName, pod.name, chosen, parentSession.shellType),
|
||||
);
|
||||
}, [contextName, namespaceName, openCommand, parentSession.shellType]);
|
||||
|
||||
const deletePod = useCallback(async (name: string) => {
|
||||
if (!contextName || !namespaceName) return;
|
||||
setPendingDelete(name);
|
||||
try {
|
||||
const result = await backend.kubectl({
|
||||
sessionId,
|
||||
op: 'delete',
|
||||
context: contextName,
|
||||
namespace: namespaceName,
|
||||
pod: name,
|
||||
});
|
||||
if (!result.success) {
|
||||
showSystemManagerError(
|
||||
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed'), contextName),
|
||||
t('common.error'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (selectedName === name) setSelectedName(null);
|
||||
await refresh();
|
||||
} finally {
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}, [backend, contextName, namespaceName, refresh, selectedName, sessionId, t]);
|
||||
|
||||
const visibleError = scopeError || error;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="kubernetes-pods">
|
||||
<SystemPanelToolbar
|
||||
trailing={(
|
||||
<SystemPanelRefreshButton
|
||||
title={t('history.action.refresh')}
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<SystemPanelSearch
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
placeholder={t('systemManager.k8s.search')}
|
||||
/>
|
||||
</SystemPanelToolbar>
|
||||
|
||||
<SystemPanelMetaBar>
|
||||
{t('systemManager.k8s.meta', { count: String(displayList.length) })}
|
||||
</SystemPanelMetaBar>
|
||||
|
||||
<SystemPanelList>
|
||||
{!contextName && !visibleError && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.noContext')} />
|
||||
)}
|
||||
{visibleError && (
|
||||
<SystemPanelError
|
||||
message={visibleError}
|
||||
onRetry={() => {
|
||||
onReloadScopes();
|
||||
void refresh();
|
||||
}}
|
||||
retryLabel={t('history.action.retry')}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
{!visibleError && contextName && displayList.length === 0 && loading && (
|
||||
<SystemPanelLoading message={t('systemManager.common.loading')} />
|
||||
)}
|
||||
{!visibleError && contextName && namespaceName && displayList.length === 0 && !loading && (
|
||||
<SystemPanelEmpty icon={Box} message={t('systemManager.k8s.empty')} />
|
||||
)}
|
||||
{displayList.map((pod) => (
|
||||
<KubernetesPodRow
|
||||
key={pod.name}
|
||||
pod={pod}
|
||||
selected={selectedName === pod.name}
|
||||
pendingDelete={pendingDelete === pod.name}
|
||||
onSelect={(item) => setSelectedName((current) => current === item.name ? null : item.name)}
|
||||
onShell={(item) => runPodCommand(item, 'shell')}
|
||||
onLogs={(item) => runPodCommand(item, 'logs')}
|
||||
onDescribe={(item) => runPodCommand(item, 'describe')}
|
||||
onDelete={(item) => setConfirmPod(item.name)}
|
||||
/>
|
||||
))}
|
||||
</SystemPanelList>
|
||||
|
||||
<SystemPanelConfirmDialog
|
||||
open={confirmPod !== null}
|
||||
title={t('systemManager.k8s.delete')}
|
||||
message={t('systemManager.k8s.confirmDelete', { name: confirmPod ?? '' })}
|
||||
confirmLabel={t('systemManager.k8s.delete')}
|
||||
destructive
|
||||
busy={pendingDelete !== null}
|
||||
onOpenChange={(open) => { if (!open) setConfirmPod(null); }}
|
||||
onConfirm={() => {
|
||||
const name = confirmPod;
|
||||
setConfirmPod(null);
|
||||
if (!name) return;
|
||||
void deletePod(name);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={containerPrompt !== null} onOpenChange={(open) => { if (!open) setContainerPrompt(null); }}>
|
||||
<DialogContent className="sm:max-w-[380px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('systemManager.k8s.chooseContainer')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-1">
|
||||
{(containerPrompt?.action === 'shell'
|
||||
? containerPrompt.pod.shellContainers
|
||||
: containerPrompt?.pod.containers
|
||||
)?.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
className="rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
onClick={() => {
|
||||
const prompt = containerPrompt;
|
||||
setContainerPrompt(null);
|
||||
if (!prompt) return;
|
||||
runPodCommand(prompt.pod, prompt.action, name);
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { resolveCapabilityPanelState } from '../../domain/systemManagerPanelStat
|
||||
import {
|
||||
allowSystemManagerMutations,
|
||||
buildSystemManagerTabs,
|
||||
hasContainerSource,
|
||||
shouldCollectServerStats,
|
||||
} from '../../domain/systemManager/systemTarget';
|
||||
|
||||
@@ -284,13 +285,14 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}, [isConnectedSession, isVisible, availableTabsKey, tabBarEl]);
|
||||
|
||||
// Must be defined before early returns to comply with React rules of hooks.
|
||||
const containerSourceReady = hasContainerSource(capabilities);
|
||||
const prevTabRef = React.useRef(resolvedTab);
|
||||
const probingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
const prev = prevTabRef.current;
|
||||
prevTabRef.current = resolvedTab;
|
||||
if (prev === resolvedTab) return;
|
||||
if (resolvedTab === 'docker' && capabilities?.hasDocker !== true) {
|
||||
if (resolvedTab === 'docker' && !containerSourceReady) {
|
||||
if (!probingRef.current) {
|
||||
probingRef.current = true;
|
||||
refreshCapabilities().finally(() => { probingRef.current = false; });
|
||||
@@ -298,7 +300,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
} else if (resolvedTab === 'tmux' && capabilities?.hasTmux !== true) {
|
||||
void refreshCapabilities();
|
||||
}
|
||||
}, [resolvedTab, capabilities, refreshCapabilities]);
|
||||
}, [resolvedTab, capabilities, containerSourceReady, refreshCapabilities]);
|
||||
|
||||
// Auto-poll for Docker capabilities while Docker tab is active and Docker not yet detected.
|
||||
// Use setTimeout recursion so the next probe only starts after the previous one finishes,
|
||||
@@ -315,7 +317,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
// preventing stale probes from surviving cleanup (unlike cancelledRef which is shared).
|
||||
// First poll is delayed by one interval to avoid overlapping with the tab-switch probe.
|
||||
React.useEffect(() => {
|
||||
if (!isVisible || resolvedTab !== 'docker' || capabilities?.hasDocker === true) return;
|
||||
if (!isVisible || resolvedTab !== 'docker' || containerSourceReady) return;
|
||||
|
||||
let cancelled = false;
|
||||
let timerId: ReturnType<typeof setTimeout>;
|
||||
@@ -344,7 +346,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [isVisible, resolvedTab, capabilities?.hasDocker, capabilitiesTtlMs]);
|
||||
}, [isVisible, resolvedTab, containerSourceReady, capabilitiesTtlMs]);
|
||||
|
||||
const selectTab = useCallback((id: SystemManagerSubTab) => {
|
||||
setActiveTab(id);
|
||||
@@ -398,7 +400,7 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
}
|
||||
|
||||
const tmuxReady = capabilities?.hasTmux === true;
|
||||
const dockerReady = capabilities?.hasDocker === true;
|
||||
const dockerReady = containerSourceReady;
|
||||
const gpuReady = capabilities?.hasNvidiaSmi === true || capabilities?.hasNpuSmi === true;
|
||||
const portsReady = (
|
||||
capabilities?.hasSs === true
|
||||
@@ -621,8 +623,10 @@ export const SystemManagerSidePanel = memo(function SystemManagerSidePanel({
|
||||
) : dockerPanelState === 'ready' ? (
|
||||
<div className={cn('flex-1 min-h-0 flex flex-col', resolvedTab !== 'docker' && 'hidden')}>
|
||||
<DockerManagerTab
|
||||
key={session.hostId || sessionId}
|
||||
sessionId={sessionId}
|
||||
parentSession={session}
|
||||
capabilities={capabilities}
|
||||
isVisible={isVisible && resolvedTab === 'docker'}
|
||||
warmupEnabled={isVisible && resolvedTab !== 'docker'}
|
||||
backend={backend}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
test('kubernetes keeps the shared runtime select and drops the Containers back button', () => {
|
||||
const tab = readFileSync(new URL('./DockerManagerTab.tsx', import.meta.url), 'utf8');
|
||||
const hook = readFileSync(
|
||||
new URL('../../application/state/systemManager/useContainerRuntimeSelection.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.equal(tab.includes('container-runtime-back'), false);
|
||||
assert.match(tab, /data-section=\{section\}/);
|
||||
assert.match(tab, /section="container-runtime-switcher"/);
|
||||
assert.match(tab, /section="k8s-context-select"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"/);
|
||||
assert.doesNotMatch(tab, /<div className="flex-1" \/>/);
|
||||
assert.match(tab, /section="k8s-context-select"[\s\S]*className="w-full min-w-0 max-w-none flex-1"/);
|
||||
assert.match(tab, /section="container-runtime-switcher"[\s\S]*className="shrink-0"/);
|
||||
assert.match(tab, /section="k8s-namespace-select"[\s\S]*className="shrink-0"/);
|
||||
assert.match(hook, /const showRuntimeSwitcher = detected\.length > 1;/);
|
||||
assert.doesNotMatch(hook, /source !== 'kubernetes' && detected\.length/);
|
||||
assert.match(hook, /reconcileContainerSource\(/);
|
||||
assert.match(hook, /isAutomaticRuntimeFallback\(remembered, source, available\)/);
|
||||
});
|
||||
@@ -93,3 +93,71 @@ test('openInteractiveTerminal keeps SSH connection reuse for ordinary connected
|
||||
assert.equal(payload.sourceSession.protocol, 'ssh');
|
||||
assert.equal(payload.sourceSession.reuseConnectionFromSessionId, 'session-1');
|
||||
});
|
||||
|
||||
async function withSystemPanels(
|
||||
panels: Array<{ getBoundingClientRect: () => { x: number; y: number; width: number; height: number } }>,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previous = globalThis.document;
|
||||
globalThis.document = {
|
||||
querySelectorAll(selector: string) {
|
||||
return selector === '[data-section="system-manager-panel"]' ? panels : [];
|
||||
},
|
||||
} as unknown as Document;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete (globalThis as { document?: Document }).document;
|
||||
} else {
|
||||
globalThis.document = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('openInteractiveTerminal passes the system panel rectangle so the shell can avoid it', async () => {
|
||||
const panel = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; width: number } }> = [];
|
||||
await withSystemPanels([panel], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; width: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
|
||||
test('openInteractiveTerminal skips a parked zero-size system panel and avoids the visible one', async () => {
|
||||
const parked = {
|
||||
getBoundingClientRect: () => ({ x: 0, y: 0, width: 0, height: 0 }),
|
||||
};
|
||||
const visible = {
|
||||
getBoundingClientRect: () => ({ x: 1000, y: 40, width: 440, height: 860 }),
|
||||
};
|
||||
const payloads: Array<{ avoidClientRect?: { x: number; y: number; width: number; height: number } }> = [];
|
||||
await withSystemPanels([parked, visible], async () => {
|
||||
await openInteractiveTerminal(
|
||||
{
|
||||
openTerminalPopup: async (payload: { avoidClientRect?: { x: number; y: number; width: number; height: number } }) => {
|
||||
payloads.push(payload);
|
||||
return { success: true };
|
||||
},
|
||||
} as never,
|
||||
parentSession(),
|
||||
'pod: multi-box',
|
||||
'kubectl exec -it multi-box -- sh',
|
||||
);
|
||||
});
|
||||
|
||||
assert.deepEqual(payloads[0]?.avoidClientRect, { x: 1000, y: 40, width: 440, height: 860 });
|
||||
});
|
||||
@@ -6,6 +6,18 @@ import type { useSystemManagerBackend } from '../../application/state/useSystemM
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
function readSystemPanelClientRect(): { x: number; y: number; width: number; height: number } | undefined {
|
||||
if (typeof document === "undefined" || typeof document.querySelectorAll !== "function") return undefined;
|
||||
const panels = document.querySelectorAll('[data-section="system-manager-panel"]');
|
||||
for (const panel of panels) {
|
||||
if (typeof panel.getBoundingClientRect !== "function") continue;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) continue;
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPopupTitle(parentSession: TerminalSession, title: string): string {
|
||||
const hostLabel = parentSession.hostLabel.trim();
|
||||
const cleanTitle = title.trim();
|
||||
@@ -62,11 +74,13 @@ export async function openInteractiveTerminal(
|
||||
canReuseConnection,
|
||||
hasIcon: !!options?.icon,
|
||||
});
|
||||
const avoidClientRect = readSystemPanelClientRect();
|
||||
const result = await backend.openTerminalPopup({
|
||||
title: popupTitle,
|
||||
icon: options?.icon,
|
||||
parentSessionId: parentSession.id,
|
||||
startupCommand,
|
||||
...(avoidClientRect ? { avoidClientRect } : {}),
|
||||
sourceSession: buildCommandPopupSourceSession(parentSession, startupCommand, canReuseConnection),
|
||||
});
|
||||
await writeSystemManagerDiagnostic('openInteractiveTerminal result', {
|
||||
|
||||
@@ -49,11 +49,11 @@ test("process and docker confirm dialogs reset when sessionId changes", () => {
|
||||
assert.match(processSource, /}, \[sessionId\]\);/);
|
||||
|
||||
assert.match(containersSource, /setConfirmAction\(null\)/);
|
||||
assert.match(containersSource, /}, \[sessionId\]\);/);
|
||||
assert.match(containersSource, /}, \[runtime, sessionId\]\);/);
|
||||
|
||||
assert.match(imagesSource, /setConfirmTarget\(null\)/);
|
||||
assert.match(imagesSource, /setActionBusy\(false\)/);
|
||||
assert.match(imagesSource, /}, \[sessionId\]\);/);
|
||||
assert.match(imagesSource, /}, \[runtime, sessionId\]\);/);
|
||||
});
|
||||
|
||||
test("ports and services confirm dialogs reset when sessionId changes", () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
sanitizeCredentialValue,
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { connectionTotpFields } from "../../../domain/totpConfig";
|
||||
import { requiresFidoSshAgentAuth } from "../../../domain/fidoSsh";
|
||||
import {
|
||||
hostRestrictsExtraSshChannels,
|
||||
@@ -478,6 +479,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
...connectionTotpFields(jumpHost),
|
||||
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
|
||||
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
|
||||
}),
|
||||
@@ -705,6 +707,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
requiresMfa: !!ctx.host.requiresMfa,
|
||||
port: ctx.host.port || 22,
|
||||
password: attempt.password,
|
||||
...connectionTotpFields(ctx.host),
|
||||
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
|
||||
? undefined
|
||||
: sftpReuseOptions,
|
||||
|
||||
@@ -8,6 +8,10 @@ import { SearchAddon } from "@xterm/addon-search";
|
||||
import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { UnicodeGraphemesAddon } from "@xterm/addon-unicode-graphemes";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { supportsTerminalArchiveCommands } from "../../../domain/terminalArchives";
|
||||
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks";
|
||||
import { createTerminalCwdMarkers } from "./terminalCwdMarkers";
|
||||
import { isTerminalReadyForCommandInjection } from "./terminalCommandInjectionReadyRegistry";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
import { Terminal as XTerm } from "@xterm/xterm";
|
||||
import type { RefObject } from "react";
|
||||
@@ -321,6 +325,8 @@ export const resetKittyKeyboardModeStateForSession = (
|
||||
export type CreateXTermRuntimeContext = {
|
||||
container: HTMLDivElement;
|
||||
host: Host;
|
||||
/** Host OS facts can arrive after the runtime is created. */
|
||||
getHost?: () => Host;
|
||||
fontFamilyId: string;
|
||||
resolvedFontFamily: string;
|
||||
fontSize: number;
|
||||
@@ -974,6 +980,16 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
terminalLinkHandler.activate(event, uri);
|
||||
});
|
||||
term.loadAddon(webLinksAddon);
|
||||
const cwdMarkers = createTerminalCwdMarkers(term);
|
||||
const archiveLinks = registerTerminalArchiveLinks({
|
||||
term,
|
||||
cwdMarkers,
|
||||
isConnected: () => ctx.statusRef.current === "connected",
|
||||
isBroadcasting: () => ctx.isBroadcastEnabledRef.current === true,
|
||||
isHostEligible: () => supportsTerminalArchiveCommands(ctx.getHost?.() ?? ctx.host, platform),
|
||||
isInputReady: () => isTerminalReadyForCommandInjection(ctx.sessionId, { allowDraft: true }),
|
||||
linkModifier: () => ctx.terminalSettingsRef.current?.linkModifier ?? "none",
|
||||
});
|
||||
const pluginLinkProviderHost = ctx.requestPluginTerminalProviders
|
||||
? registerPluginTerminalLinkProvider({
|
||||
term,
|
||||
@@ -3250,12 +3266,14 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
if (path && path.length > 0) {
|
||||
currentCwd = path;
|
||||
ctx.onCwdChange?.(path);
|
||||
cwdMarkers.note({ cwd: path, host: url.hostname });
|
||||
logger.debug('[XTerm] OSC 7 CWD update:', path);
|
||||
}
|
||||
} else if (data.startsWith('/')) {
|
||||
// Some shells send just the path without file:// prefix
|
||||
currentCwd = data;
|
||||
ctx.onCwdChange?.(data);
|
||||
cwdMarkers.note({ cwd: data, host: '' });
|
||||
logger.debug('[XTerm] OSC 7 CWD update (raw path):', data);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -3504,6 +3522,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
keywordHighlighter.dispose();
|
||||
cursorLineHighlighter.dispose();
|
||||
pluginLinkProviderHost?.dispose();
|
||||
archiveLinks.dispose();
|
||||
cwdMarkers.dispose();
|
||||
pluginProviderHost?.dispose();
|
||||
eraseScrollbackDisposable.dispose();
|
||||
for (const disposable of cursorPositionReportRequestDisposables) {
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { ILink, ILinkProvider, Terminal } from "@xterm/xterm";
|
||||
|
||||
import { createTerminalCwdMarkers } from "./terminalCwdMarkers.ts";
|
||||
import { registerTerminalArchiveLinks } from "./terminalArchiveLinks.ts";
|
||||
import type { LinkModifier } from "../../../domain/models/terminal.ts";
|
||||
|
||||
type PrintedLine = { text: string; wrapped: boolean };
|
||||
|
||||
function click(partial: Partial<MouseEvent> = {}): MouseEvent {
|
||||
return {
|
||||
button: 0,
|
||||
ctrlKey: false,
|
||||
altKey: false,
|
||||
metaKey: false,
|
||||
...partial,
|
||||
} as MouseEvent;
|
||||
}
|
||||
|
||||
function makeBufferLine(line: PrintedLine) {
|
||||
const chars = Array.from(line.text);
|
||||
return {
|
||||
isWrapped: line.wrapped,
|
||||
length: chars.length,
|
||||
translateToString(trim?: boolean) {
|
||||
return trim ? line.text.trimEnd() : line.text;
|
||||
},
|
||||
getCell(index: number) {
|
||||
const ch = chars[index];
|
||||
if (ch === undefined) return undefined;
|
||||
return {
|
||||
getChars: () => ch,
|
||||
getWidth: () => 1,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
let lines: PrintedLine[] = [{ text: "/tmp/backup.tar.gz", wrapped: false }];
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
let baseY = 0;
|
||||
let cols = 80;
|
||||
let cursorLine = 0;
|
||||
let type: "normal" | "alternate" = "normal";
|
||||
let connected = true;
|
||||
let broadcast = false;
|
||||
let eligible = true;
|
||||
let ready = true;
|
||||
let modifier: LinkModifier = "none";
|
||||
const pasted: string[] = [];
|
||||
let provider!: ILinkProvider;
|
||||
|
||||
const term = {
|
||||
get cols() {
|
||||
return cols;
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
get type() {
|
||||
return type;
|
||||
},
|
||||
get cursorX() {
|
||||
return cursorX;
|
||||
},
|
||||
get cursorY() {
|
||||
return cursorY;
|
||||
},
|
||||
get baseY() {
|
||||
return baseY;
|
||||
},
|
||||
getLine(index: number) {
|
||||
const line = lines[index];
|
||||
return line ? makeBufferLine(line) : undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
registerMarker(offset: number) {
|
||||
return {
|
||||
line: cursorLine + offset,
|
||||
isDisposed: false,
|
||||
dispose() {
|
||||
this.isDisposed = true;
|
||||
},
|
||||
};
|
||||
},
|
||||
paste(value: string) {
|
||||
pasted.push(value);
|
||||
},
|
||||
focus() {},
|
||||
registerLinkProvider(next: ILinkProvider) {
|
||||
provider = next;
|
||||
return { dispose() {} };
|
||||
},
|
||||
};
|
||||
|
||||
const cwdMarkers = createTerminalCwdMarkers(term as never);
|
||||
registerTerminalArchiveLinks({
|
||||
term: term as unknown as Terminal,
|
||||
cwdMarkers,
|
||||
isConnected: () => connected,
|
||||
isBroadcasting: () => broadcast,
|
||||
isHostEligible: () => eligible,
|
||||
isInputReady: () => ready,
|
||||
linkModifier: () => modifier,
|
||||
});
|
||||
|
||||
const linksAt = (y: number) => {
|
||||
let value: ILink[] | undefined;
|
||||
provider.provideLinks(y, (result) => {
|
||||
value = result;
|
||||
});
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
pasted,
|
||||
linksAt,
|
||||
noteAt(line: number, report: { cwd: string; host: string }, x = 0) {
|
||||
cursorLine = line;
|
||||
const previousX = cursorX;
|
||||
cursorX = x;
|
||||
cwdMarkers.note(report);
|
||||
cursorX = previousX;
|
||||
},
|
||||
setLines(next: PrintedLine[]) {
|
||||
lines = next;
|
||||
},
|
||||
setLine(index: number, text: string, wrapped = false) {
|
||||
lines[index] = { text, wrapped };
|
||||
},
|
||||
setCursor(line: number, x: number) {
|
||||
cursorY = line;
|
||||
cursorX = x;
|
||||
baseY = 0;
|
||||
},
|
||||
atPrompt() {
|
||||
const prompt = lines.length;
|
||||
lines = [...lines, { text: "", wrapped: false }];
|
||||
cursorY = prompt;
|
||||
cursorX = 0;
|
||||
baseY = 0;
|
||||
},
|
||||
setBroadcast(value: boolean) {
|
||||
broadcast = value;
|
||||
},
|
||||
setReady(value: boolean) {
|
||||
ready = value;
|
||||
},
|
||||
setEligible(value: boolean) {
|
||||
eligible = value;
|
||||
},
|
||||
setConnected(value: boolean) {
|
||||
connected = value;
|
||||
},
|
||||
setModifier(value: LinkModifier) {
|
||||
modifier = value;
|
||||
},
|
||||
setAltBuffer(value: boolean) {
|
||||
type = value ? "alternate" : "normal";
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const ctrl = click({ ctrlKey: true });
|
||||
|
||||
test("no modifier is a no-op, Ctrl fills one command, and Cmd works on macOS", () => {
|
||||
const linux = fixture();
|
||||
const link = linux.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
assert.deepEqual(link.range, { start: { x: 1, y: 1 }, end: { x: 18, y: 1 } });
|
||||
linux.atPrompt();
|
||||
link.activate(click(), link.text);
|
||||
assert.deepEqual(linux.pasted, []);
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(linux.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
|
||||
assert.equal(linux.pasted[0]?.includes("\r"), false);
|
||||
assert.equal(linux.pasted[0]?.includes("\n"), false);
|
||||
|
||||
const mac = fixture();
|
||||
const macLink = mac.linksAt(1)?.[0];
|
||||
assert.ok(macLink);
|
||||
mac.atPrompt();
|
||||
macLink.activate(click({ metaKey: true }), macLink.text);
|
||||
assert.deepEqual(mac.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
|
||||
});
|
||||
|
||||
test("a configured alt modifier is required for archive links", () => {
|
||||
const f = fixture();
|
||||
f.setModifier("alt");
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(f.pasted, []);
|
||||
link.activate(click({ altKey: true }), link.text);
|
||||
assert.equal(f.pasted.length, 1);
|
||||
assert.equal(f.pasted[0]?.includes("\r"), false);
|
||||
});
|
||||
|
||||
test("broadcast, readiness, host eligibility, buffer, and stale text gate provide and activate", () => {
|
||||
const f = fixture();
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
|
||||
f.setBroadcast(true);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setBroadcast(false);
|
||||
|
||||
f.setReady(false);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setReady(true);
|
||||
|
||||
f.setEligible(false);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setEligible(true);
|
||||
|
||||
f.setConnected(false);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setConnected(true);
|
||||
|
||||
f.setAltBuffer(true);
|
||||
assert.equal(f.linksAt(1), undefined);
|
||||
link.activate(ctrl, link.text);
|
||||
f.setAltBuffer(false);
|
||||
|
||||
f.setLine(0, "changed.tar.gz");
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(f.pasted, []);
|
||||
});
|
||||
|
||||
test("a non-space before the cursor is kept and separated with one space", () => {
|
||||
const draft = fixture();
|
||||
draft.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo", wrapped: false },
|
||||
]);
|
||||
draft.setCursor(1, 4);
|
||||
const link = draft.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
link.activate(ctrl, link.text);
|
||||
assert.equal(draft.pasted[0], " tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'");
|
||||
|
||||
const spaced = fixture();
|
||||
spaced.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "sudo ", wrapped: false },
|
||||
]);
|
||||
spaced.setCursor(1, 5);
|
||||
const spacedLink = spaced.linksAt(1)?.[0];
|
||||
assert.ok(spacedLink);
|
||||
spacedLink.activate(ctrl, spacedLink.text);
|
||||
assert.equal(spaced.pasted[0], "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'");
|
||||
});
|
||||
|
||||
test("a non-space after the cursor is separated with one space", () => {
|
||||
const command = "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'";
|
||||
const before = fixture();
|
||||
before.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo", wrapped: false },
|
||||
]);
|
||||
before.setCursor(1, 0);
|
||||
const beforeLink = before.linksAt(1)?.[0];
|
||||
assert.ok(beforeLink);
|
||||
beforeLink.activate(ctrl, beforeLink.text);
|
||||
assert.equal(before.pasted[0], `${command} `);
|
||||
assert.equal(before.pasted[0]?.includes("\r"), false);
|
||||
|
||||
const middle = fixture();
|
||||
middle.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo", wrapped: false },
|
||||
]);
|
||||
middle.setCursor(1, 2);
|
||||
const middleLink = middle.linksAt(1)?.[0];
|
||||
assert.ok(middleLink);
|
||||
middleLink.activate(ctrl, middleLink.text);
|
||||
assert.equal(middle.pasted[0], ` ${command} `);
|
||||
|
||||
const already = fixture();
|
||||
already.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "echo foo", wrapped: false },
|
||||
]);
|
||||
already.setCursor(1, 4);
|
||||
const alreadyLink = already.linksAt(1)?.[0];
|
||||
assert.ok(alreadyLink);
|
||||
alreadyLink.activate(ctrl, alreadyLink.text);
|
||||
assert.equal(already.pasted[0], ` ${command}`);
|
||||
});
|
||||
|
||||
test("a wrapped draft separates the command from the previous row", () => {
|
||||
const command = "tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'";
|
||||
const full = " ".repeat(79);
|
||||
|
||||
const glued = fixture();
|
||||
glued.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: `${full}o`, wrapped: false },
|
||||
{ text: " ", wrapped: true },
|
||||
]);
|
||||
glued.setCursor(2, 0);
|
||||
const gluedLink = glued.linksAt(1)?.[0];
|
||||
assert.ok(gluedLink);
|
||||
gluedLink.activate(ctrl, gluedLink.text);
|
||||
assert.equal(glued.pasted[0], ` ${command}`);
|
||||
|
||||
const spaced = fixture();
|
||||
spaced.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: `${full} `, wrapped: false },
|
||||
{ text: " ", wrapped: true },
|
||||
]);
|
||||
spaced.setCursor(2, 0);
|
||||
const spacedLink = spaced.linksAt(1)?.[0];
|
||||
assert.ok(spacedLink);
|
||||
spacedLink.activate(ctrl, spacedLink.text);
|
||||
assert.equal(spaced.pasted[0], command);
|
||||
|
||||
const separate = fixture();
|
||||
separate.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: `${full}o`, wrapped: false },
|
||||
{ text: " ", wrapped: false },
|
||||
]);
|
||||
separate.setCursor(2, 0);
|
||||
const separateLink = separate.linksAt(1)?.[0];
|
||||
assert.ok(separateLink);
|
||||
separateLink.activate(ctrl, separateLink.text);
|
||||
assert.equal(separate.pasted[0], command);
|
||||
|
||||
const continued = fixture();
|
||||
continued.setLines([
|
||||
{ text: "/tmp/backup.tar.gz", wrapped: false },
|
||||
{ text: "e".repeat(80), wrapped: false },
|
||||
{ text: "x", wrapped: true },
|
||||
]);
|
||||
continued.setCursor(1, 80);
|
||||
const continuedLink = continued.linksAt(1)?.[0];
|
||||
assert.ok(continuedLink);
|
||||
continuedLink.activate(ctrl, continuedLink.text);
|
||||
assert.equal(continued.pasted[0], ` ${command} `);
|
||||
});
|
||||
|
||||
test("a name that starts on the next wrapped row is not anchored on the previous row", () => {
|
||||
const f = fixture();
|
||||
f.setLines([
|
||||
{ text: "x ", wrapped: false },
|
||||
{ text: "/tmp/a.tar", wrapped: true },
|
||||
]);
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
assert.equal(link.text, "/tmp/a.tar");
|
||||
assert.deepEqual(link.range, {
|
||||
start: { x: 1, y: 2 },
|
||||
end: { x: 10, y: 2 },
|
||||
});
|
||||
|
||||
const ended = fixture();
|
||||
ended.setLines([
|
||||
{ text: "/a.tar", wrapped: false },
|
||||
{ text: " rest", wrapped: true },
|
||||
]);
|
||||
const endedLink = ended.linksAt(1)?.[0];
|
||||
assert.ok(endedLink);
|
||||
assert.equal(endedLink.text, "/a.tar");
|
||||
assert.deepEqual(endedLink.range, {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 6, y: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
test("a wrapped logical line is one multi-row link and fills the full path", () => {
|
||||
const f = fixture();
|
||||
f.setLines([
|
||||
{ text: "/tmp/back", wrapped: false },
|
||||
{ text: "up.tar.gz", wrapped: true },
|
||||
]);
|
||||
const link = f.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
assert.deepEqual(link.range, {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 9, y: 2 },
|
||||
});
|
||||
f.atPrompt();
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(f.pasted, ["tar -xvzf '/tmp/backup.tar.gz' -C '/tmp'"]);
|
||||
});
|
||||
|
||||
test("absolute names link without markers and relative names do not", () => {
|
||||
const relative = fixture();
|
||||
relative.setLine(0, "bundle.tar.gz");
|
||||
assert.equal(relative.linksAt(1), undefined);
|
||||
|
||||
const absolute = fixture();
|
||||
absolute.setLine(0, "/var/pack.zip");
|
||||
const link = absolute.linksAt(1)?.[0];
|
||||
assert.ok(link);
|
||||
absolute.atPrompt();
|
||||
link.activate(ctrl, link.text);
|
||||
assert.deepEqual(absolute.pasted, ["unzip '/var/pack.zip' -d '/var'"]);
|
||||
|
||||
const open = fixture();
|
||||
open.setLines([
|
||||
{ text: "prompt", wrapped: false },
|
||||
{ text: "/var/pack.zip", wrapped: false },
|
||||
]);
|
||||
open.noteAt(0, { cwd: "/var", host: "box" });
|
||||
const openLink = open.linksAt(2)?.[0];
|
||||
assert.ok(openLink);
|
||||
open.atPrompt();
|
||||
openLink.activate(ctrl, openLink.text);
|
||||
assert.equal(open.pasted[0], "unzip '/var/pack.zip' -d '/var'");
|
||||
open.setLine(1, "pack.zip");
|
||||
assert.equal(open.linksAt(2), undefined);
|
||||
|
||||
const mismatched = fixture();
|
||||
mismatched.setLines([
|
||||
{ text: "prompt", wrapped: false },
|
||||
{ text: "/tmp/a.tar.gz", wrapped: false },
|
||||
{ text: "prompt", wrapped: false },
|
||||
]);
|
||||
mismatched.noteAt(0, { cwd: "/tmp", host: "box" });
|
||||
mismatched.noteAt(2, { cwd: "/other", host: "box" });
|
||||
const mismatchLink = mismatched.linksAt(2)?.[0];
|
||||
assert.ok(mismatchLink);
|
||||
mismatched.atPrompt();
|
||||
mismatchLink.activate(ctrl, mismatchLink.text);
|
||||
assert.equal(mismatched.pasted[0], "tar -xvzf '/tmp/a.tar.gz' -C '/tmp'");
|
||||
mismatched.setLine(1, "a.tar.gz");
|
||||
assert.equal(mismatched.linksAt(2), undefined);
|
||||
});
|
||||
|
||||
test("an absolute path from another host is not a link", () => {
|
||||
const f = fixture();
|
||||
f.setLines([
|
||||
{ text: "prompt", wrapped: false },
|
||||
{ text: "/tmp/a.tar.gz", wrapped: false },
|
||||
{ text: "prompt", wrapped: false },
|
||||
]);
|
||||
f.noteAt(0, { cwd: "/tmp", host: "outer" });
|
||||
f.noteAt(2, { cwd: "/tmp", host: "outer" });
|
||||
f.noteAt(4, { cwd: "/home", host: "inner" });
|
||||
assert.equal(f.linksAt(2), undefined);
|
||||
});
|
||||
|
||||
test("ls -l links only the archive name and a symlink line links both names", () => {
|
||||
const listing = fixture();
|
||||
listing.setLines([
|
||||
{ text: "$ ls -l", wrapped: false },
|
||||
{ text: "-rw-r--r-- 1 me me 10 Jan 1 00:00 bundle.tar.gz", wrapped: false },
|
||||
{ text: "$", wrapped: false },
|
||||
]);
|
||||
listing.noteAt(0, { cwd: "/data", host: "box" });
|
||||
listing.noteAt(2, { cwd: "/data", host: "box" });
|
||||
const links = listing.linksAt(2);
|
||||
assert.deepEqual(links?.map((link) => link.text), ["bundle.tar.gz"]);
|
||||
listing.atPrompt();
|
||||
links?.[0]?.activate(ctrl, links[0].text);
|
||||
assert.deepEqual(listing.pasted, ["tar -xvzf '/data/bundle.tar.gz' -C '/data'"]);
|
||||
|
||||
const symlink = fixture();
|
||||
symlink.setLines([
|
||||
{ text: "$ ls", wrapped: false },
|
||||
{ text: "a.tar.gz -> b.tar.gz", wrapped: false },
|
||||
{ text: "$", wrapped: false },
|
||||
]);
|
||||
symlink.noteAt(0, { cwd: "/data", host: "box" });
|
||||
symlink.noteAt(2, { cwd: "/data", host: "box" });
|
||||
assert.deepEqual(symlink.linksAt(2)?.map((link) => link.text), ["a.tar.gz", "b.tar.gz"]);
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { IDisposable, ILink, ILinkProvider, Terminal } from "@xterm/xterm";
|
||||
|
||||
import {
|
||||
buildArchiveExtractCommand,
|
||||
findTerminalArchiveTokens,
|
||||
resolveArchivePath,
|
||||
} from "../../../domain/terminalArchives";
|
||||
import type { LinkModifier } from "../../../domain/models/terminal";
|
||||
import {
|
||||
readPluginTerminalBufferText,
|
||||
type PluginTerminalBufferText,
|
||||
} from "../pluginTerminalBufferText";
|
||||
import type { TerminalCwdInspection, TerminalCwdMarkers } from "./terminalCwdMarkers";
|
||||
|
||||
const MAX_LOGICAL_LINE_CHARS = 8192;
|
||||
|
||||
export interface TerminalArchiveLinkHost {
|
||||
term: Terminal;
|
||||
cwdMarkers: TerminalCwdMarkers;
|
||||
isConnected(): boolean;
|
||||
isBroadcasting(): boolean;
|
||||
isHostEligible(): boolean;
|
||||
isInputReady(): boolean;
|
||||
linkModifier(): LinkModifier;
|
||||
}
|
||||
|
||||
interface LogicalSegment {
|
||||
lineNo: number;
|
||||
text: PluginTerminalBufferText;
|
||||
stringStart: number;
|
||||
}
|
||||
|
||||
interface LogicalLine {
|
||||
text: string;
|
||||
segments: LogicalSegment[];
|
||||
}
|
||||
|
||||
interface BufferCell {
|
||||
line: number;
|
||||
cell: number;
|
||||
}
|
||||
|
||||
function readLogicalLine(term: Terminal, oneBasedY: number): LogicalLine | null {
|
||||
const buffer = term.buffer.active;
|
||||
const index = oneBasedY - 1;
|
||||
if (!buffer.getLine(index)) return null;
|
||||
let start = index;
|
||||
while (start > 0 && buffer.getLine(start)?.isWrapped) start -= 1;
|
||||
|
||||
const segments: LogicalSegment[] = [];
|
||||
let total = 0;
|
||||
for (let lineNo = start; ; lineNo += 1) {
|
||||
const line = buffer.getLine(lineNo);
|
||||
if (!line) break;
|
||||
if (lineNo > start && !line.isWrapped) break;
|
||||
const next = buffer.getLine(lineNo + 1);
|
||||
const isLast = !next?.isWrapped;
|
||||
const text = readPluginTerminalBufferText(line, isLast);
|
||||
if (total + text.text.length > MAX_LOGICAL_LINE_CHARS) return null;
|
||||
segments.push({ lineNo, text, stringStart: total });
|
||||
total += text.text.length;
|
||||
if (isLast) break;
|
||||
}
|
||||
if (segments.length === 0) return null;
|
||||
return {
|
||||
text: segments.map((segment) => segment.text.text).join(""),
|
||||
segments,
|
||||
};
|
||||
}
|
||||
|
||||
function cellAt(
|
||||
segments: readonly LogicalSegment[],
|
||||
offset: number,
|
||||
bound: "inclusive" | "exclusive",
|
||||
): BufferCell | null {
|
||||
for (const segment of segments) {
|
||||
const local = offset - segment.stringStart;
|
||||
if (local < 0) return null;
|
||||
const length = segment.text.text.length;
|
||||
// A boundary offset is the next row's first cell for a token start, and the
|
||||
// previous row's exclusive end for a match that stops on that row.
|
||||
if (bound === "inclusive" ? local >= length : local > length) continue;
|
||||
const cell = segment.text.cellAtStringOffset[local];
|
||||
if (!Number.isInteger(cell)) return null;
|
||||
return { line: segment.lineNo, cell };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sameSpan(left: TerminalCwdInspection, right: TerminalCwdInspection): boolean {
|
||||
if (left.kind !== right.kind) return false;
|
||||
if (left.kind === "ambiguous") return false;
|
||||
if (left.kind === "outside") return true;
|
||||
if (left.kind === "open") return right.kind === "open" && left.host === right.host;
|
||||
return right.kind === "closed" && left.key === right.key;
|
||||
}
|
||||
|
||||
function modifierMatches(event: MouseEvent, modifier: LinkModifier): boolean {
|
||||
if (event.button !== 0) return false;
|
||||
if (modifier === "ctrl") return event.ctrlKey;
|
||||
if (modifier === "alt") return event.altKey;
|
||||
if (modifier === "meta") return event.metaKey;
|
||||
// "none" still requires Ctrl or Cmd. A configured modifier replaces that default.
|
||||
return event.ctrlKey || event.metaKey;
|
||||
}
|
||||
|
||||
function neighborNeedsSpace(term: Terminal, direction: -1 | 1): boolean {
|
||||
const buffer = term.buffer.active;
|
||||
let lineIndex = buffer.baseY + buffer.cursorY;
|
||||
let line = buffer.getLine(lineIndex);
|
||||
if (!line || typeof line.getCell !== "function") return false;
|
||||
let index = buffer.cursorX + (direction < 0 ? -1 : 0);
|
||||
for (let steps = 0; steps < term.cols + 1; steps += 1) {
|
||||
if (index < 0 || index >= term.cols) {
|
||||
if (direction < 0) {
|
||||
if (!line.isWrapped) return false;
|
||||
lineIndex -= 1;
|
||||
} else {
|
||||
lineIndex += 1;
|
||||
const next = buffer.getLine(lineIndex);
|
||||
if (!next?.isWrapped) return false;
|
||||
}
|
||||
line = buffer.getLine(lineIndex);
|
||||
if (!line || typeof line.getCell !== "function") return false;
|
||||
index = direction < 0 ? term.cols - 1 : 0;
|
||||
continue;
|
||||
}
|
||||
const cell = line.getCell(index);
|
||||
if (!cell) return false;
|
||||
if (cell.getWidth() === 0) {
|
||||
index += direction;
|
||||
continue;
|
||||
}
|
||||
return (cell.getChars() || " ") !== " ";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl/Cmd-click archive names to paste an absolute extract command.
|
||||
* Directory labels come only from OSC 7 markers; input is never observed.
|
||||
*/
|
||||
export function registerTerminalArchiveLinks(options: TerminalArchiveLinkHost): IDisposable {
|
||||
let disposed = false;
|
||||
const { term, cwdMarkers } = options;
|
||||
|
||||
const sessionAllowsLinks = (): boolean => options.isConnected()
|
||||
&& !options.isBroadcasting()
|
||||
&& options.isHostEligible()
|
||||
&& options.isInputReady()
|
||||
&& term.buffer.active.type === "normal";
|
||||
|
||||
const cwdFor = (path: string, start: BufferCell, end: BufferCell): string | null | undefined => {
|
||||
const left = cwdMarkers.inspectCell(start.line, start.cell);
|
||||
const right = cwdMarkers.inspectCell(end.line, end.cell);
|
||||
if (!sameSpan(left, right) || left.kind === "ambiguous") return undefined;
|
||||
const absolute = path.startsWith("/");
|
||||
if (absolute) {
|
||||
if (left.kind === "outside") return null;
|
||||
const latest = cwdMarkers.latestHost();
|
||||
if (latest != null && left.host !== latest) return undefined;
|
||||
return null;
|
||||
}
|
||||
if (left.kind !== "closed" || !left.trusted) return undefined;
|
||||
const latest = cwdMarkers.latestHost();
|
||||
if (latest != null && left.host !== latest) return undefined;
|
||||
return left.cwd;
|
||||
};
|
||||
|
||||
const provider: ILinkProvider = {
|
||||
provideLinks(bufferLineNumber, callback) {
|
||||
if (disposed || !sessionAllowsLinks()) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const logical = readLogicalLine(term, bufferLineNumber);
|
||||
if (!logical || logical.text.length === 0) {
|
||||
callback(undefined);
|
||||
return;
|
||||
}
|
||||
const links: ILink[] = [];
|
||||
for (const token of findTerminalArchiveTokens(logical.text)) {
|
||||
const start = cellAt(logical.segments, token.start, "inclusive");
|
||||
const endExclusive = cellAt(logical.segments, token.start + token.length, "exclusive");
|
||||
const last = cellAt(logical.segments, token.start + token.length - 1, "inclusive");
|
||||
if (!start || !endExclusive || !last) continue;
|
||||
const cwd = cwdFor(token.path, start, last);
|
||||
if (cwd === undefined) continue;
|
||||
const abs = resolveArchivePath(token.path, cwd);
|
||||
const command = abs ? buildArchiveExtractCommand(abs) : null;
|
||||
if (!command) continue;
|
||||
const capturedText = logical.text;
|
||||
links.push({
|
||||
range: {
|
||||
start: { x: start.cell + 1, y: start.line + 1 },
|
||||
end: { x: endExclusive.cell, y: endExclusive.line + 1 },
|
||||
},
|
||||
text: token.text,
|
||||
decorations: { pointerCursor: true, underline: true },
|
||||
activate(event) {
|
||||
if (disposed || !modifierMatches(event, options.linkModifier())) return;
|
||||
if (!sessionAllowsLinks()) return;
|
||||
const fresh = readLogicalLine(term, start.line + 1);
|
||||
if (!fresh || fresh.text !== capturedText) return;
|
||||
const freshStart = cellAt(fresh.segments, token.start, "inclusive");
|
||||
const freshLast = cellAt(fresh.segments, token.start + token.length - 1, "inclusive");
|
||||
if (!freshStart || !freshLast) return;
|
||||
const freshCwd = cwdFor(token.path, freshStart, freshLast);
|
||||
if (freshCwd === undefined) return;
|
||||
const freshAbs = resolveArchivePath(token.path, freshCwd);
|
||||
const freshCommand = freshAbs ? buildArchiveExtractCommand(freshAbs) : null;
|
||||
if (!freshCommand || /[\r\n]/u.test(freshCommand)) return;
|
||||
const lead = neighborNeedsSpace(term, -1) ? " " : "";
|
||||
const trail = neighborNeedsSpace(term, 1) ? " " : "";
|
||||
term.paste(`${lead}${freshCommand}${trail}`);
|
||||
term.focus();
|
||||
},
|
||||
});
|
||||
}
|
||||
callback(links.length > 0 ? links : undefined);
|
||||
},
|
||||
};
|
||||
|
||||
const registration = term.registerLinkProvider(provider);
|
||||
return {
|
||||
dispose() {
|
||||
disposed = true;
|
||||
registration.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
const commandInjectionReadyReaders = new Map<string, () => boolean>();
|
||||
export interface TerminalCommandInjectionReadyOptions {
|
||||
/** Keep a typed draft. Sensitive prompts, alt-screen, and non-prompts still block. */
|
||||
allowDraft?: boolean;
|
||||
}
|
||||
|
||||
const commandInjectionReadyReaders = new Map<
|
||||
string,
|
||||
(options?: TerminalCommandInjectionReadyOptions) => boolean
|
||||
>();
|
||||
|
||||
export function registerTerminalCommandInjectionReadyReader(
|
||||
sessionId: string,
|
||||
reader: () => boolean,
|
||||
reader: (options?: TerminalCommandInjectionReadyOptions) => boolean,
|
||||
): () => void {
|
||||
commandInjectionReadyReaders.set(sessionId, reader);
|
||||
return () => {
|
||||
@@ -12,7 +20,10 @@ export function registerTerminalCommandInjectionReadyReader(
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the live terminal reports an idle shell prompt ready for injection. */
|
||||
export function isTerminalReadyForCommandInjection(sessionId: string): boolean {
|
||||
return commandInjectionReadyReaders.get(sessionId)?.() === true;
|
||||
/** True when the live terminal reports a shell prompt ready for injection. */
|
||||
export function isTerminalReadyForCommandInjection(
|
||||
sessionId: string,
|
||||
options?: TerminalCommandInjectionReadyOptions,
|
||||
): boolean {
|
||||
return commandInjectionReadyReaders.get(sessionId)?.(options) === true;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { createTerminalCwdMarkers } from "./terminalCwdMarkers.ts";
|
||||
|
||||
type FakeMarker = {
|
||||
line: number;
|
||||
isDisposed: boolean;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
function harness() {
|
||||
let cursorLine = 0;
|
||||
let cursorX = 0;
|
||||
let cols = 80;
|
||||
let type: "normal" | "alternate" = "normal";
|
||||
const created: FakeMarker[] = [];
|
||||
const term = {
|
||||
get cols() {
|
||||
return cols;
|
||||
},
|
||||
buffer: {
|
||||
active: {
|
||||
get type() {
|
||||
return type;
|
||||
},
|
||||
get cursorX() {
|
||||
return cursorX;
|
||||
},
|
||||
},
|
||||
},
|
||||
registerMarker(offset: number) {
|
||||
const marker: FakeMarker = {
|
||||
line: cursorLine + offset,
|
||||
isDisposed: false,
|
||||
dispose() {
|
||||
this.isDisposed = true;
|
||||
},
|
||||
};
|
||||
created.push(marker);
|
||||
return marker;
|
||||
},
|
||||
};
|
||||
const markers = createTerminalCwdMarkers(term as never);
|
||||
return {
|
||||
markers,
|
||||
created,
|
||||
setCursor(line: number, x = 0) {
|
||||
cursorLine = line;
|
||||
cursorX = x;
|
||||
},
|
||||
setCols(next: number) {
|
||||
cols = next;
|
||||
},
|
||||
setType(next: "normal" | "alternate") {
|
||||
type = next;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("prompt-only intervals are trusted only when both ends report the same cwd", () => {
|
||||
const same = harness();
|
||||
same.setCursor(0);
|
||||
same.markers.note({ cwd: "/a", host: "box" });
|
||||
same.setCursor(3);
|
||||
same.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.deepEqual(same.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
|
||||
|
||||
const changed = harness();
|
||||
changed.setCursor(0);
|
||||
changed.markers.note({ cwd: "/a", host: "box" });
|
||||
changed.setCursor(3);
|
||||
changed.markers.note({ cwd: "/a/x", host: "box" });
|
||||
assert.equal(changed.markers.resolveCell(1, 0), null);
|
||||
|
||||
const thenCd = harness();
|
||||
thenCd.setCursor(0);
|
||||
thenCd.markers.note({ cwd: "/a", host: "box" });
|
||||
thenCd.setCursor(4);
|
||||
thenCd.markers.note({ cwd: "/a/x", host: "box" });
|
||||
assert.equal(thenCd.markers.resolveCell(2, 0), null);
|
||||
});
|
||||
|
||||
test("a chpwd emitter trusts the interval after the directory change", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(2);
|
||||
h.markers.note({ cwd: "/a/x", host: "box" });
|
||||
h.setCursor(5);
|
||||
h.markers.note({ cwd: "/a/x", host: "box" });
|
||||
assert.equal(h.markers.resolveCell(1, 0), null);
|
||||
assert.deepEqual(h.markers.resolveCell(3, 0), { cwd: "/a/x", host: "box" });
|
||||
});
|
||||
|
||||
test("the open interval after the latest marker is not trusted", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(2);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.deepEqual(h.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
|
||||
assert.equal(h.markers.resolveCell(4, 0), null);
|
||||
});
|
||||
|
||||
test("a host mismatch between interval ends is not trusted and latestHost follows the last marker", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "outer" });
|
||||
h.setCursor(2);
|
||||
h.markers.note({ cwd: "/a", host: "inner" });
|
||||
assert.equal(h.markers.resolveCell(1, 0), null);
|
||||
assert.equal(h.markers.latestHost(), "inner");
|
||||
});
|
||||
|
||||
test("a disposed start or end marker drops the interval", () => {
|
||||
const start = harness();
|
||||
start.setCursor(0);
|
||||
start.markers.note({ cwd: "/a", host: "box" });
|
||||
start.setCursor(2);
|
||||
start.markers.note({ cwd: "/a", host: "box" });
|
||||
start.created[0]?.dispose();
|
||||
assert.equal(start.markers.resolveCell(1, 0), null);
|
||||
|
||||
const end = harness();
|
||||
end.setCursor(0);
|
||||
end.markers.note({ cwd: "/a", host: "box" });
|
||||
end.setCursor(2);
|
||||
end.markers.note({ cwd: "/a", host: "box" });
|
||||
end.created[1]?.dispose();
|
||||
assert.equal(end.markers.resolveCell(1, 0), null);
|
||||
});
|
||||
|
||||
test("a new marker above existing ones drops the later markers", () => {
|
||||
const h = harness();
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(5);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(10);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
h.setCursor(3);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.equal(h.created[1]?.isDisposed, true);
|
||||
assert.equal(h.created[2]?.isDisposed, true);
|
||||
assert.equal(h.created[0]?.isDisposed, false);
|
||||
assert.deepEqual(h.markers.resolveCell(1, 0), { cwd: "/a", host: "box" });
|
||||
assert.equal(h.markers.resolveCell(6, 0), null);
|
||||
});
|
||||
|
||||
test("cells left of a marker belong to the previous interval and a cols change makes that row ambiguous", () => {
|
||||
const split = harness();
|
||||
split.setCursor(0, 0);
|
||||
split.markers.note({ cwd: "/old", host: "box" });
|
||||
split.setCursor(5, 10);
|
||||
split.markers.note({ cwd: "/new", host: "box" });
|
||||
split.setCursor(9, 0);
|
||||
split.markers.note({ cwd: "/new", host: "box" });
|
||||
assert.equal(split.markers.resolveCell(5, 9), null);
|
||||
assert.deepEqual(split.markers.resolveCell(5, 10), { cwd: "/new", host: "box" });
|
||||
assert.deepEqual(split.markers.resolveCell(7, 0), { cwd: "/new", host: "box" });
|
||||
|
||||
const resized = harness();
|
||||
resized.setCursor(0, 0);
|
||||
resized.markers.note({ cwd: "/a", host: "box" });
|
||||
resized.setCursor(5, 4);
|
||||
resized.markers.note({ cwd: "/a", host: "box" });
|
||||
resized.setCursor(9, 0);
|
||||
resized.markers.note({ cwd: "/a", host: "box" });
|
||||
resized.setCols(100);
|
||||
assert.equal(resized.markers.resolveCell(5, 0), null);
|
||||
assert.equal(resized.markers.resolveCell(5, 4), null);
|
||||
assert.equal(resized.markers.resolveCell(0, 1), null);
|
||||
assert.deepEqual(resized.markers.resolveCell(3, 0), { cwd: "/a", host: "box" });
|
||||
});
|
||||
|
||||
test("the alternate buffer does not record cwd markers", () => {
|
||||
const h = harness();
|
||||
h.setType("alternate");
|
||||
h.setCursor(0);
|
||||
h.markers.note({ cwd: "/a", host: "box" });
|
||||
assert.equal(h.created.length, 0);
|
||||
assert.equal(h.markers.resolveCell(0, 0), null);
|
||||
assert.equal(h.markers.latestHost(), null);
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
const MAX_CWD_MARKERS = 5000;
|
||||
|
||||
interface CwdMarker {
|
||||
readonly line: number;
|
||||
readonly isDisposed: boolean;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface TerminalCwdMarkerTerminal {
|
||||
readonly cols: number;
|
||||
readonly buffer: {
|
||||
readonly active: {
|
||||
readonly type: "normal" | "alternate";
|
||||
readonly cursorX: number;
|
||||
};
|
||||
};
|
||||
registerMarker(cursorYOffset: number): CwdMarker | undefined;
|
||||
}
|
||||
|
||||
interface CwdMarkerEntry {
|
||||
id: number;
|
||||
marker: CwdMarker;
|
||||
x: number;
|
||||
cols: number;
|
||||
cwd: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface TerminalCwdCell {
|
||||
cwd: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export type TerminalCwdInspection =
|
||||
| { kind: "outside" }
|
||||
| { kind: "ambiguous" }
|
||||
| { kind: "open"; host: string; cwd: string }
|
||||
| { kind: "closed"; host: string; cwd: string; trusted: boolean; key: string };
|
||||
|
||||
export interface TerminalCwdMarkers {
|
||||
note(report: { cwd: string; host: string }): void;
|
||||
resolveCell(row: number, col: number): TerminalCwdCell | null;
|
||||
inspectCell(row: number, col: number): TerminalCwdInspection;
|
||||
latestHost(): string | null;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* OSC 7 cwd labels. Output is trusted only between two markers that agree.
|
||||
* Nothing here watches keyboard or paste input.
|
||||
*/
|
||||
export function createTerminalCwdMarkers(term: TerminalCwdMarkerTerminal): TerminalCwdMarkers {
|
||||
let entries: CwdMarkerEntry[] = [];
|
||||
let nextId = 1;
|
||||
let disposed = false;
|
||||
|
||||
const liveEntries = (): CwdMarkerEntry[] => {
|
||||
if (entries.some((entry) => entry.marker.isDisposed)) {
|
||||
entries = entries.filter((entry) => !entry.marker.isDisposed);
|
||||
}
|
||||
for (let index = 1; index < entries.length; index += 1) {
|
||||
if (entries[index - 1].marker.line > entries[index].marker.line) {
|
||||
entries.sort((left, right) => left.marker.line - right.marker.line);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
const relation = (
|
||||
row: number,
|
||||
col: number,
|
||||
entry: CwdMarkerEntry,
|
||||
): "before" | "atOrAfter" | "ambiguous" => {
|
||||
const line = entry.marker.line;
|
||||
if (row < line) return "before";
|
||||
if (row > line) return "atOrAfter";
|
||||
if (term.cols !== entry.cols) return "ambiguous";
|
||||
return col < entry.x ? "before" : "atOrAfter";
|
||||
};
|
||||
|
||||
const inspectCell = (row: number, col: number): TerminalCwdInspection => {
|
||||
const list = liveEntries();
|
||||
if (list.length === 0) return { kind: "outside" };
|
||||
let start: CwdMarkerEntry | null = null;
|
||||
for (const entry of list) {
|
||||
const where = relation(row, col, entry);
|
||||
if (where === "ambiguous") return { kind: "ambiguous" };
|
||||
if (where === "before") {
|
||||
if (!start) return { kind: "outside" };
|
||||
if (start.host !== entry.host) return { kind: "ambiguous" };
|
||||
return {
|
||||
kind: "closed",
|
||||
host: start.host,
|
||||
cwd: start.cwd,
|
||||
trusted: start.cwd === entry.cwd,
|
||||
key: `${start.id}:${entry.id}`,
|
||||
};
|
||||
}
|
||||
start = entry;
|
||||
}
|
||||
if (!start) return { kind: "outside" };
|
||||
return { kind: "open", host: start.host, cwd: start.cwd };
|
||||
};
|
||||
|
||||
return {
|
||||
note(report) {
|
||||
if (disposed || term.buffer.active.type !== "normal") return;
|
||||
const marker = term.registerMarker(0);
|
||||
if (!marker) return;
|
||||
const fresh: CwdMarkerEntry = {
|
||||
id: nextId,
|
||||
marker,
|
||||
x: term.buffer.active.cursorX,
|
||||
cols: term.cols,
|
||||
cwd: report.cwd,
|
||||
host: report.host,
|
||||
};
|
||||
nextId += 1;
|
||||
const kept: CwdMarkerEntry[] = [];
|
||||
for (const entry of entries) {
|
||||
if (entry.marker.isDisposed || entry.marker.line >= marker.line) {
|
||||
if (!entry.marker.isDisposed) entry.marker.dispose();
|
||||
continue;
|
||||
}
|
||||
kept.push(entry);
|
||||
}
|
||||
kept.push(fresh);
|
||||
while (kept.length > MAX_CWD_MARKERS) {
|
||||
const removed = kept.shift();
|
||||
if (removed && !removed.marker.isDisposed) removed.marker.dispose();
|
||||
}
|
||||
entries = kept;
|
||||
},
|
||||
inspectCell,
|
||||
resolveCell(row, col) {
|
||||
const inspection = inspectCell(row, col);
|
||||
if (inspection.kind !== "closed" || !inspection.trusted) return null;
|
||||
return { cwd: inspection.cwd, host: inspection.host };
|
||||
},
|
||||
latestHost() {
|
||||
const list = liveEntries();
|
||||
return list.length > 0 ? list[list.length - 1].host : null;
|
||||
},
|
||||
dispose() {
|
||||
disposed = true;
|
||||
for (const entry of entries) {
|
||||
if (!entry.marker.isDisposed) entry.marker.dispose();
|
||||
}
|
||||
entries = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -214,6 +214,8 @@ export function resolveSelectionOverlayPosition(term: any, container: HTMLElemen
|
||||
|
||||
export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const { CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef, onPluginRuntimeCwdChange, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRules, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, onSnippetShortkeyRef, snippetsRef, splitResizeActive, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState, vaultInitialized } = ctx;
|
||||
const archiveHostRef = useRef(host);
|
||||
archiveHostRef.current = host;
|
||||
const effectiveTerminalProtocol = resolveEffectiveTerminalProtocol(host);
|
||||
const hibernateHiddenTabs = resolveTerminalHibernateEnabledForProtocol(
|
||||
terminalSettings,
|
||||
@@ -529,6 +531,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
const runtime = createXTermRuntime({
|
||||
container: containerRef.current,
|
||||
host,
|
||||
getHost: () => archiveHostRef.current,
|
||||
fontFamilyId,
|
||||
resolvedFontFamily,
|
||||
fontSize,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const toastSource = readFileSync(new URL("./toast.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("toast surfaces follow app theme tokens instead of OS palette fills", () => {
|
||||
const styles = toastSource.slice(
|
||||
toastSource.indexOf("const TOAST_STYLES"),
|
||||
toastSource.indexOf("export const ToastProvider"),
|
||||
);
|
||||
|
||||
assert.match(styles, /success: 'border-emerald-600'/);
|
||||
assert.match(styles, /error: 'border-red-600'/);
|
||||
assert.match(styles, /warning: 'border-yellow-600'/);
|
||||
assert.match(styles, /info: 'border-blue-600'/);
|
||||
// `dark:` is prefers-color-scheme, so these fills stay pale while
|
||||
// text-foreground / text-muted-foreground follow the `.dark` class.
|
||||
assert.doesNotMatch(styles, /bg-(?:emerald|red|yellow|blue)-50/);
|
||||
assert.doesNotMatch(styles, /dark:bg-(?:emerald|red|yellow|blue)-950/);
|
||||
assert.match(toastSource, /"bg-card animate-in/);
|
||||
assert.match(toastSource, /text-sm font-medium text-foreground/);
|
||||
assert.match(toastSource, /text-sm text-muted-foreground break-words/);
|
||||
});
|
||||
@@ -82,11 +82,15 @@ const TOAST_ICONS: Record<ToastType, React.ReactNode> = {
|
||||
info: <Info className="h-4 w-4 text-blue-500" />,
|
||||
};
|
||||
|
||||
// Colored `bg-*-50` / `dark:bg-*-950` fills follow the OS color scheme.
|
||||
// Title and body use `.dark` tokens (`text-foreground`, `text-muted-foreground`),
|
||||
// so an app-dark / OS-light session paints light text on a pale fill.
|
||||
// The container's `bg-card` tracks the app theme; the border and icon mark the variant.
|
||||
const TOAST_STYLES: Record<ToastType, string> = {
|
||||
success: 'border-emerald-600 bg-emerald-50 dark:bg-emerald-950',
|
||||
error: 'border-red-600 bg-red-50 dark:bg-red-950',
|
||||
warning: 'border-yellow-600 bg-yellow-50 dark:bg-yellow-950',
|
||||
info: 'border-blue-600 bg-blue-50 dark:bg-blue-950',
|
||||
success: 'border-emerald-600',
|
||||
error: 'border-red-600',
|
||||
warning: 'border-yellow-600',
|
||||
info: 'border-blue-600',
|
||||
};
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
|
||||
@@ -360,6 +360,36 @@ test("VaultHostListSection exposes normal group cards to keyboard", () => {
|
||||
assert.match(markup, /data-group-path="production"[^>]*tabindex="0"/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection keeps selected host cards draggable and leaves unselected cards in place", () => {
|
||||
const otherHost = makeHost("other-host", "Other Router");
|
||||
const markup = renderHostList({
|
||||
viewMode: "list",
|
||||
displayedGroups: [],
|
||||
displayedHosts: [mainHost, otherHost],
|
||||
visibleDisplayedHosts: [mainHost, otherHost],
|
||||
isMultiSelectMode: true,
|
||||
selectedHostIds: new Set([mainHost.id]),
|
||||
});
|
||||
|
||||
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
|
||||
assert.match(markup, /data-host-id="other-host"[^>]*draggable="false"/);
|
||||
const source = readFileSync(new URL("./VaultHostListSection.tsx", import.meta.url), "utf8");
|
||||
assert.match(source, /useVaultHostMarquee/);
|
||||
assert.match(source, /data-vault-marquee/);
|
||||
assert.match(source, /moveHostsToGroup/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection keeps a normal host card draggable outside multi-select", () => {
|
||||
const markup = renderHostList({
|
||||
viewMode: "grid",
|
||||
displayedGroups: [],
|
||||
displayedHosts: [mainHost],
|
||||
visibleDisplayedHosts: [mainHost],
|
||||
});
|
||||
|
||||
assert.match(markup, /data-host-id="main-host"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("VaultHostListSection exposes ungrouped hosts to keyboard and assistive technology", () => {
|
||||
const markup = renderHostList({
|
||||
viewMode: "list",
|
||||
|
||||
@@ -27,7 +27,10 @@ import {
|
||||
import type { GroupNode, Host } from "../../domain/models";
|
||||
import { isPluginHostProtocol } from "../../domain/pluginConnection";
|
||||
import { OpenDualPaneSftpMenuItem, OpenHostBrowserMenuItem } from "../host/HostTreeContextMenus";
|
||||
import { VAULT_HOST_DRAG_IDS, isVaultHostDraggable } from "../../domain/vaultHostDrag";
|
||||
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
|
||||
import { beginVaultHostCardDrag } from "./vaultHostDragImage";
|
||||
import { useVaultHostMarquee } from "./useVaultHostMarquee";
|
||||
|
||||
type VaultHostListSectionContext = Record<string, any>;
|
||||
|
||||
@@ -54,12 +57,15 @@ const isRelatedTargetInside = (
|
||||
};
|
||||
|
||||
const EMPTY_GROUP_PATH_SET = new Set<string>();
|
||||
const EMPTY_HOST_ID_SET: ReadonlySet<string> = new Set();
|
||||
const noopSetMultiSelectMode = (_enabled: boolean) => {};
|
||||
const noopSetSelectedHostIds = (_ids: Set<string>) => {};
|
||||
|
||||
export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext }) {
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, moveHostsToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsMultiSelectMode = noopSetMultiSelectMode, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setSelectedHostIds = noopSetSelectedHostIds, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
|
||||
const hostClickBehavior: HostClickBehavior = hostClickBehaviorProp === 'select' ? 'select' : 'connect';
|
||||
const multiSelectedGroupPaths: Set<string> = selectedGroupPaths ?? EMPTY_GROUP_PATH_SET;
|
||||
const [draggingHostId, setDraggingHostId] = React.useState<string | null>(null);
|
||||
const [draggingHostIds, setDraggingHostIds] = React.useState<ReadonlySet<string> | null>(null);
|
||||
const draggingHostIdRef = React.useRef<string | null>(null);
|
||||
const lastPreviewReorderRef = React.useRef<string | null>(null);
|
||||
const prepareGridLayoutAnimation = useVaultGridLayoutAnimation(hostListScrollRef);
|
||||
@@ -120,6 +126,19 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
setFocusedGroupPath(null);
|
||||
}, [selectedGroupPath, viewMode, hostClickBehavior]);
|
||||
|
||||
const {
|
||||
marqueeRect,
|
||||
onPointerDown: onMarqueePointerDown,
|
||||
onClickCapture: onMarqueeClickCapture,
|
||||
} = useVaultHostMarquee({
|
||||
containerRef: hostListScrollRef,
|
||||
viewMode,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds: selectedHostIds ?? EMPTY_HOST_ID_SET,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
});
|
||||
|
||||
const activateHost = React.useCallback((host: Host) => {
|
||||
const action = resolveHostActivateAction({
|
||||
behavior: hostClickBehavior,
|
||||
@@ -284,17 +303,25 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
|
||||
const resetHostDragState = React.useCallback(() => {
|
||||
draggingHostIdRef.current = null;
|
||||
setDraggingHostId(null);
|
||||
setDraggingHostIds(null);
|
||||
lastPreviewReorderRef.current = null;
|
||||
setDragOverDropTarget(null);
|
||||
}, [setDragOverDropTarget]);
|
||||
|
||||
const handleHostDragStart = React.useCallback((e: React.DragEvent, hostId: string) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", hostId);
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId,
|
||||
isMultiSelectMode: !!isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
draggingHostIdRef.current = hostId;
|
||||
setDraggingHostId(hostId);
|
||||
setDraggingHostIds(new Set(ids));
|
||||
lastPreviewReorderRef.current = null;
|
||||
|
||||
// Grid preview reorder can move the card into another virtual row, and rows
|
||||
@@ -308,7 +335,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
resetHostDragState();
|
||||
};
|
||||
sourceNode.addEventListener("dragend", handleNativeDragEnd);
|
||||
}, [resetHostDragState]);
|
||||
}, [isMultiSelectMode, resetHostDragState, selectedHostIds, t]);
|
||||
|
||||
const renderHostEditButton = (host: any, compact = false) => (
|
||||
<Button
|
||||
@@ -351,14 +378,21 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
return <div
|
||||
ref={hostListScrollRef}
|
||||
className={cn(
|
||||
"flex-1 overflow-auto px-4 pb-4 space-y-3",
|
||||
"relative flex-1 overflow-auto px-4 pb-4 [&>:not([data-vault-marquee])~:not([data-vault-marquee])]:mt-3",
|
||||
viewMode === "tree" ? "pt-1.5" : "pt-0",
|
||||
!isHostsSectionActive && "hidden",
|
||||
marqueeRect && "select-none",
|
||||
)}
|
||||
data-section="vault-host-list"
|
||||
onClick={handleHostListClick}
|
||||
onClickCapture={onMarqueeClickCapture}
|
||||
onPointerDown={onMarqueePointerDown}
|
||||
onDragOverCapture={(e) => {
|
||||
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
|
||||
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
|
||||
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
|
||||
if (!groupTarget) return;
|
||||
}
|
||||
if (target) e.preventDefault();
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const draggedGroupPath = e.dataTransfer.getData("group-path");
|
||||
@@ -392,6 +426,10 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
const target = (e.target as Element | null)?.closest("[data-host-id], [data-group-path]");
|
||||
if (hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)) {
|
||||
const groupTarget = target instanceof HTMLElement && target.hasAttribute("data-group-path");
|
||||
if (!groupTarget) return;
|
||||
}
|
||||
if (!(target instanceof HTMLElement) || viewMode === "grid") return;
|
||||
const draggedGroupPath = e.dataTransfer.getData("group-path");
|
||||
const isDraggingGroup = hasVaultDragType(e.dataTransfer, "group-path");
|
||||
@@ -419,7 +457,12 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
if (!(target instanceof HTMLElement)) return;
|
||||
const targetHostId = target.getAttribute("data-host-id");
|
||||
const targetGroupPath = target.getAttribute("data-group-path");
|
||||
if (draggedHostId && targetHostId && draggedHostId !== targetHostId) {
|
||||
if (
|
||||
draggedHostId
|
||||
&& targetHostId
|
||||
&& draggedHostId !== targetHostId
|
||||
&& !hasVaultDragType(e.dataTransfer, VAULT_HOST_DRAG_IDS)
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const position = getVaultDropPosition(target, e.clientX, e.clientY, viewMode === "grid");
|
||||
@@ -447,6 +490,19 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
resetHostDragState();
|
||||
}}
|
||||
>
|
||||
{marqueeRect && (
|
||||
<div
|
||||
data-vault-marquee="true"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute z-20 border border-primary/80 bg-primary/15"
|
||||
style={{
|
||||
left: marqueeRect.left,
|
||||
top: marqueeRect.top,
|
||||
width: marqueeRect.right - marqueeRect.left,
|
||||
height: marqueeRect.bottom - marqueeRect.top,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{viewMode !== "tree" && (
|
||||
<section className="space-y-2 pt-2">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
@@ -476,6 +532,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
setDragOverDropTarget,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
});
|
||||
}}
|
||||
@@ -546,7 +603,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -565,7 +622,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
style={lastPinnedId === host.id ? { animation: "pop-in 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both" } : undefined}
|
||||
onAnimationEnd={() => { if (lastPinnedId === host.id) setLastPinnedId(null); }}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -672,7 +729,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -689,7 +746,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -794,6 +851,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: selectedGroupPath,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
})) return;
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
@@ -874,6 +932,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState,
|
||||
})) return;
|
||||
const groupPath =
|
||||
@@ -983,6 +1042,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
cancelInlineGroupEdit={cancelInlineGroupEdit}
|
||||
onDeleteGroup={startInlineDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
@@ -1041,7 +1101,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -1058,7 +1118,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
@@ -1192,7 +1252,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
viewMode === "grid"
|
||||
? cn(
|
||||
"soft-card elevate rounded-xl h-[68px] px-3 py-2 will-change-transform transition-[opacity,box-shadow,border-color,background-color] duration-150",
|
||||
draggingHostId === host.id && "opacity-45",
|
||||
draggingHostIds?.has(host.id) && "opacity-45",
|
||||
hostCardFocusClassName(viewMode, isHostFocusSelected(host.id)),
|
||||
)
|
||||
: cn(
|
||||
@@ -1209,7 +1269,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
? selectedHostIds.has(host.id)
|
||||
: undefined}
|
||||
tabIndex={getHostTabIndex(host.id)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => handleHostDragStart(e, host.id)}
|
||||
onClick={() => {
|
||||
activateHost(safeHost);
|
||||
|
||||
@@ -1007,42 +1007,43 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
{/* Fixed w-44 flex-shrinks the shell icon when the label is longer than the siblings. */}
|
||||
<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
className="justify-start gap-2"
|
||||
onClick={handleNewLocalShellHost}
|
||||
>
|
||||
<TerminalSquare size={14} /> {t("vault.hosts.newLocalShell")}
|
||||
<TerminalSquare size={14} className="shrink-0" /> {t("vault.hosts.newLocalShell")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
className="justify-start gap-2"
|
||||
onClick={() => {
|
||||
setTargetParentPath(selectedGroupPath);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
|
||||
<FolderTree size={14} className="shrink-0" /> {t("vault.hosts.newGroup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
className="justify-start gap-2"
|
||||
disabled={importProgress?.status === "running"}
|
||||
onClick={() => {
|
||||
if (importProgress?.status === "running") return;
|
||||
setIsImportOpen(true);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} /> {t("vault.hosts.import")}
|
||||
<Upload size={14} className="shrink-0" /> {t("vault.hosts.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
className="justify-start gap-2"
|
||||
onClick={handleExportHosts}
|
||||
>
|
||||
<Download size={14} /> {t("vault.hosts.export")}
|
||||
<Download size={14} className="shrink-0" /> {t("vault.hosts.export")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
@@ -1236,6 +1237,7 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
managedGroupPaths,
|
||||
moveGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
onDeleteHost,
|
||||
Pin,
|
||||
pinnedHosts,
|
||||
@@ -1255,10 +1257,12 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
|
||||
setDragOverDropTarget,
|
||||
setGroupDragOverDropTarget,
|
||||
setIsDeleteGroupOpen,
|
||||
setIsMultiSelectMode,
|
||||
setIsNewFolderOpen,
|
||||
setLastPinnedId,
|
||||
setNewFolderName,
|
||||
setSelectedGroupPath,
|
||||
setSelectedHostIds,
|
||||
setTargetParentPath,
|
||||
shouldHideEmptyRootHostsSection,
|
||||
showRecentHosts,
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import type { Host, ManagedSource } from '../../types';
|
||||
import { handleVaultHostDropToGroup } from './vaultReorderDrag';
|
||||
import { useVaultGroupDragHandlers } from './useVaultGroupDragHandlers';
|
||||
|
||||
const host = (id: string, extra: Partial<Host> = {}): Host => ({
|
||||
@@ -44,6 +45,37 @@ test('mixed protocols entering and leaving managed groups retain single-host mov
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('a multi-id group drop publishes the whole selection through the batched move', async () => {
|
||||
const original = [host('ssh'), host('telnet', { protocol: 'telnet' }), host('local', { protocol: 'local' })];
|
||||
const sources = [{ id: 'parent', groupName: 'Managed' }, { id: 'child', groupName: 'Managed/Nested' }] as ManagedSource[];
|
||||
const writes: Host[][] = [];
|
||||
await withActions(original, sources, actions => {
|
||||
let handled = false;
|
||||
act(() => {
|
||||
handled = handleVaultHostDropToGroup({
|
||||
dataTransfer: {
|
||||
getData: (type: string) => type === 'host-ids' ? JSON.stringify(['ssh', 'telnet', 'local']) : '',
|
||||
},
|
||||
groupPath: 'Managed/Nested',
|
||||
moveHostToGroup: () => {
|
||||
throw new Error('multi-id drop must not move hosts one at a time');
|
||||
},
|
||||
moveHostsToGroup: actions.moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
});
|
||||
});
|
||||
assert.equal(handled, true);
|
||||
assert.equal(writes.length, 1);
|
||||
assert.deepEqual(writes[0].map(item => item.group), ['Managed/Nested', 'Managed/Nested', 'Managed/Nested']);
|
||||
assert.equal(writes[0][0].managedSourceId, 'child');
|
||||
assert.equal(writes[0][0].label, 'Hostssh');
|
||||
assert.equal(writes[0][1].managedSourceId, undefined);
|
||||
assert.equal(writes[0][1].label, 'Host telnet');
|
||||
assert.equal(writes[0][2].managedSourceId, undefined);
|
||||
assert.equal(writes[0][2].label, 'Host local');
|
||||
}, writes);
|
||||
});
|
||||
|
||||
test('same-group, missing and empty selections do not publish changes', async () => {
|
||||
const writes: Host[][] = [];
|
||||
await withActions([host('a')], [], actions => {
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
clientPointToContent,
|
||||
contentRectFromClientBox,
|
||||
exceedsMarqueeThreshold,
|
||||
hostIdsIntersectingMarquee,
|
||||
isPointerOnNativeScrollbar,
|
||||
marqueeAutoScrollDelta,
|
||||
marqueeCancelSelection,
|
||||
marqueeCommitSelection,
|
||||
marqueeRectFromPoints,
|
||||
marqueeSelectionMode,
|
||||
mergeMarqueeSelection,
|
||||
rememberHostPlacements,
|
||||
shouldStartVaultMarquee,
|
||||
type HostPlacement,
|
||||
type MarqueeContentFrame,
|
||||
type MarqueeRect,
|
||||
} from "../../domain/vaultMarqueeSelection";
|
||||
|
||||
const MARQUEE_BLOCKED_SELECTOR = [
|
||||
"[data-host-id]",
|
||||
"[data-group-path]",
|
||||
"button",
|
||||
"input",
|
||||
"textarea",
|
||||
"select",
|
||||
"a",
|
||||
"label",
|
||||
"[role='scrollbar']",
|
||||
].join(",");
|
||||
|
||||
const selectionSignature = (ids: ReadonlySet<string>) => [...ids].sort().join("\0");
|
||||
|
||||
const elementFromEventTarget = (target: EventTarget | null): Element | null => {
|
||||
if (target instanceof Element) return target;
|
||||
if (typeof Node !== "undefined" && target instanceof Node) return target.parentElement;
|
||||
return null;
|
||||
};
|
||||
|
||||
const isBlockedMarqueeTarget = (target: EventTarget | null): boolean => {
|
||||
const element = elementFromEventTarget(target);
|
||||
if (!element) return true;
|
||||
return Boolean(element.closest(MARQUEE_BLOCKED_SELECTOR));
|
||||
};
|
||||
|
||||
const readFrame = (container: HTMLElement): MarqueeContentFrame => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
scrollLeft: container.scrollLeft,
|
||||
scrollTop: container.scrollTop,
|
||||
clientLeft: container.clientLeft,
|
||||
clientTop: container.clientTop,
|
||||
};
|
||||
};
|
||||
|
||||
const readVisibleHostPlacements = (container: HTMLElement): HostPlacement[] => {
|
||||
const frame = readFrame(container);
|
||||
const placements: HostPlacement[] = [];
|
||||
container.querySelectorAll<HTMLElement>("[data-host-id]").forEach((node, index) => {
|
||||
const id = node.dataset.hostId;
|
||||
if (!id) return;
|
||||
const box = node.getBoundingClientRect();
|
||||
placements.push({
|
||||
key: node.dataset.vaultGridItem || `${id}:${index}`,
|
||||
id,
|
||||
rect: contentRectFromClientBox(box, frame),
|
||||
});
|
||||
});
|
||||
return placements;
|
||||
};
|
||||
|
||||
interface UseVaultHostMarqueeOptions {
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
viewMode: string;
|
||||
isMultiSelectMode: boolean;
|
||||
selectedHostIds: ReadonlySet<string>;
|
||||
setIsMultiSelectMode?: (value: boolean) => void;
|
||||
setSelectedHostIds?: (ids: Set<string>) => void;
|
||||
}
|
||||
|
||||
export function useVaultHostMarquee({
|
||||
containerRef,
|
||||
viewMode,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
}: UseVaultHostMarqueeOptions) {
|
||||
const [marqueeRect, setMarqueeRect] = useState<MarqueeRect | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const sessionRef = useRef(0);
|
||||
const stopGestureRef = useRef<(() => void) | null>(null);
|
||||
|
||||
useEffect(() => () => stopGestureRef.current?.(), []);
|
||||
|
||||
const onPointerDown = useCallback((event: React.PointerEvent<HTMLElement>) => {
|
||||
if (suppressClickRef.current) suppressClickRef.current = false;
|
||||
const container = containerRef.current;
|
||||
if (!container || !setSelectedHostIds || !setIsMultiSelectMode) return;
|
||||
const bounds = container.getBoundingClientRect();
|
||||
if (!shouldStartVaultMarquee({
|
||||
viewMode,
|
||||
button: event.button,
|
||||
pointerType: event.pointerType,
|
||||
onScrollbar: isPointerOnNativeScrollbar({
|
||||
rectLeft: bounds.left,
|
||||
rectTop: bounds.top,
|
||||
clientLeft: container.clientLeft,
|
||||
clientTop: container.clientTop,
|
||||
clientWidth: container.clientWidth,
|
||||
clientHeight: container.clientHeight,
|
||||
}, event.clientX, event.clientY),
|
||||
blockedTarget: isBlockedMarqueeTarget(event.target),
|
||||
})) return;
|
||||
|
||||
stopGestureRef.current?.();
|
||||
const startContent = clientPointToContent(
|
||||
{ x: event.clientX, y: event.clientY },
|
||||
readFrame(container),
|
||||
);
|
||||
const baseline = new Set(selectedHostIds);
|
||||
const wasMultiSelectMode = Boolean(isMultiSelectMode);
|
||||
const mode = marqueeSelectionMode(event);
|
||||
const cache = new Map<string, HostPlacement>();
|
||||
const session = sessionRef.current + 1;
|
||||
sessionRef.current = session;
|
||||
let active = false;
|
||||
let scrollDelta = 0;
|
||||
let frameId = 0;
|
||||
let lastClientX = event.clientX;
|
||||
let lastClientY = event.clientY;
|
||||
let lastSignature = selectionSignature(baseline);
|
||||
let lastMode = wasMultiSelectMode;
|
||||
|
||||
const publish = (nextIds: Set<string>, modeEnabled: boolean) => {
|
||||
const signature = selectionSignature(nextIds);
|
||||
if (signature !== lastSignature) {
|
||||
lastSignature = signature;
|
||||
setSelectedHostIds(nextIds);
|
||||
}
|
||||
if (modeEnabled !== lastMode) {
|
||||
lastMode = modeEnabled;
|
||||
setIsMultiSelectMode(modeEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
const applyAt = (clientX: number, clientY: number) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const current = clientPointToContent({ x: clientX, y: clientY }, readFrame(container));
|
||||
if (!active && !exceedsMarqueeThreshold(startContent, current)) return;
|
||||
active = true;
|
||||
const rect = marqueeRectFromPoints(startContent, current);
|
||||
rememberHostPlacements(cache, readVisibleHostPlacements(container));
|
||||
const hits = hostIdsIntersectingMarquee(rect, cache.values());
|
||||
const next = marqueeCommitSelection({
|
||||
wasMultiSelectMode,
|
||||
nextIds: mergeMarqueeSelection(baseline, hits, mode),
|
||||
});
|
||||
setMarqueeRect(rect);
|
||||
publish(next.selectedHostIds, next.isMultiSelectMode);
|
||||
};
|
||||
|
||||
const listeners: {
|
||||
move?: (event: PointerEvent) => void;
|
||||
up?: () => void;
|
||||
cancel?: () => void;
|
||||
key?: (event: KeyboardEvent) => void;
|
||||
scroll?: () => void;
|
||||
} = {};
|
||||
let stopGesture = () => {};
|
||||
|
||||
const finishListeners = () => {
|
||||
if (listeners.move) window.removeEventListener("pointermove", listeners.move);
|
||||
if (listeners.up) window.removeEventListener("pointerup", listeners.up);
|
||||
if (listeners.cancel) window.removeEventListener("pointercancel", listeners.cancel);
|
||||
if (listeners.key) window.removeEventListener("keydown", listeners.key);
|
||||
if (listeners.scroll) container.removeEventListener("scroll", listeners.scroll);
|
||||
if (frameId) cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
scrollDelta = 0;
|
||||
if (stopGestureRef.current === stopGesture) stopGestureRef.current = null;
|
||||
};
|
||||
|
||||
stopGesture = () => {
|
||||
if (sessionRef.current !== session) return;
|
||||
sessionRef.current += 1;
|
||||
finishListeners();
|
||||
setMarqueeRect(null);
|
||||
};
|
||||
|
||||
const end = (cancel: boolean) => {
|
||||
if (sessionRef.current !== session) return;
|
||||
const wasActive = active;
|
||||
stopGesture();
|
||||
if (!wasActive) return;
|
||||
suppressClickRef.current = true;
|
||||
if (!cancel) return;
|
||||
const restored = marqueeCancelSelection({
|
||||
wasMultiSelectMode,
|
||||
baselineIds: baseline,
|
||||
});
|
||||
lastSignature = "";
|
||||
lastMode = !restored.isMultiSelectMode;
|
||||
publish(restored.selectedHostIds, restored.isMultiSelectMode);
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
frameId = 0;
|
||||
if (!scrollDelta || sessionRef.current !== session) return;
|
||||
container.scrollTop += scrollDelta;
|
||||
frameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
listeners.move = (pointerEvent: PointerEvent) => {
|
||||
lastClientX = pointerEvent.clientX;
|
||||
lastClientY = pointerEvent.clientY;
|
||||
applyAt(pointerEvent.clientX, pointerEvent.clientY);
|
||||
if (active) {
|
||||
pointerEvent.preventDefault();
|
||||
window.getSelection?.()?.removeAllRanges();
|
||||
}
|
||||
const nextBounds = container.getBoundingClientRect();
|
||||
scrollDelta = active
|
||||
? marqueeAutoScrollDelta(pointerEvent.clientY, nextBounds.top, nextBounds.bottom)
|
||||
: 0;
|
||||
if (scrollDelta && !frameId) frameId = window.requestAnimationFrame(tick);
|
||||
if (!scrollDelta && frameId) {
|
||||
cancelAnimationFrame(frameId);
|
||||
frameId = 0;
|
||||
}
|
||||
};
|
||||
|
||||
listeners.scroll = () => {
|
||||
if (!active) return;
|
||||
applyAt(lastClientX, lastClientY);
|
||||
};
|
||||
listeners.up = () => end(false);
|
||||
listeners.cancel = () => end(true);
|
||||
listeners.key = (keyEvent: KeyboardEvent) => {
|
||||
if (keyEvent.key !== "Escape") return;
|
||||
if (!active) {
|
||||
end(false);
|
||||
return;
|
||||
}
|
||||
keyEvent.preventDefault();
|
||||
keyEvent.stopPropagation();
|
||||
end(true);
|
||||
};
|
||||
|
||||
stopGestureRef.current = stopGesture;
|
||||
window.addEventListener("pointermove", listeners.move);
|
||||
window.addEventListener("pointerup", listeners.up);
|
||||
window.addEventListener("pointercancel", listeners.cancel);
|
||||
window.addEventListener("keydown", listeners.key);
|
||||
container.addEventListener("scroll", listeners.scroll, { passive: true });
|
||||
}, [
|
||||
containerRef,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
setIsMultiSelectMode,
|
||||
setSelectedHostIds,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const onClickCapture = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
||||
if (!suppressClickRef.current) return;
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
return { marqueeRect, onPointerDown, onClickCapture };
|
||||
}
|
||||
Loaded 100 of 292 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user