Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 310ab602c4 test: share the global default proxy settings harness
The peer-window case and the multi-instance cleanup case were mounting
the same JSDOM fixture twice.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:27:31 +00:00
Cursor Agentand陈大猫 277ac31f3f refactor: clear a deleted global default proxy at the call site
Drop the pass-through field on removeProxyProfileReferences. The profile
manager already knows the active id and clears it directly.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:26:45 +00:00
Cursor Agentand陈大猫 44bcc92c89 fix: follow global default proxy changes in peer session windows
Session windows disable settings IPC sync, which also skipped the
storage listener and left the resolver singleton at its mount-time id.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:26:45 +00:00
Cursor Agentand陈大猫 e30055d70e fix: keep CSV proxy export from embedding the global default
Grouped hosts ran through applyGroupDefaults and picked up the ambient
global proxy, while ungrouped hosts exported an empty inheriting cell.
Both paths now opt out of that default so inheritance stays inheritance.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:26:45 +00:00
Cursor Agentand陈大猫 3546b2baf4 fix: round-trip explicit direct mode in vault CSV
Write the Proxy cell as direct when a host sets directProxy, and import that literal back as an explicit direct connection. An empty Proxy cell still inherits.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:50:22 +00:00
Cursor Agentand陈大猫 d6a13e3a8b fix: keep global default proxy persistence across settings instances
Unregister only the setter owned by the unmounting useSettingsState instance so closing Settings still writes the cleared default through the remaining AppLockGate setter.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:50:22 +00:00
Cursor Agentand陈大猫 735368e697 feat: add a global default proxy with an explicit direct override
Resolve the proxy as the host setting, then the nearest group default,
then one optional saved profile from synced settings, then a direct
connection. A host can choose direct and skip both inherited defaults.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:10:10 +00:00
386 changed files with 1060 additions and 39023 deletions

No files matched your search

-1
View File
@@ -130,7 +130,6 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
- **分割ペイン** — 水平・垂直分割でマルチタスク - **分割ペイン** — 水平・垂直分割でマルチタスク
- **セッション管理** — 複数の接続を並行して扱う - **セッション管理** — 複数の接続を並行して扱う
- **インライン画像** — リモートプログラムが出力する Kitty グラフィックス、SIXEL、iTerm インライン画像を描画 - **インライン画像** — リモートプログラムが出力する 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 + 内蔵エディタ ### 📁 SFTP + 内蔵エディタ
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード - **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
-26
View File
@@ -9,12 +9,6 @@
<a href="https://netcatty.app"><strong>netcatty.app</strong></a> <a href="https://netcatty.app"><strong>netcatty.app</strong></a>
</p> </p>
<p align="center">
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&amp;utm_medium=badge&amp;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"> <p align="center">
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/> 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. 🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
@@ -93,7 +87,6 @@ Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one c
- [Split Terminals](#split-terminals) - [Split Terminals](#split-terminals)
- [Supported Distros](#supported-distros) - [Supported Distros](#supported-distros)
- [Getting Started](#getting-started) - [Getting Started](#getting-started)
- [Open a local terminal from the command line](#open-a-local-terminal-from-the-command-line)
- [Build & Package](#build--package) - [Build & Package](#build--package)
- [Tech Stack](#tech-stack) - [Tech Stack](#tech-stack)
- [Contributing](#contributing) - [Contributing](#contributing)
@@ -139,7 +132,6 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
- **Session management** — run multiple connections side-by-side - **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. - **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 - **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 ### 📁 SFTP + Built-in Editor
- **File workflows** — drag & drop uploads/downloads - **File workflows** — drag & drop uploads/downloads
@@ -249,24 +241,6 @@ application and integration are complete.
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases. > **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
<a name="open-a-local-terminal-from-the-command-line"></a>
### Open a local terminal from the command line
`--open-terminal-path <dir>` opens a local terminal in that folder. `--open-terminal-shell <id-or-path>` selects the shell: a discovered id such as `pwsh`, `cmd`, or `wsl-ubuntu`, or a path to a shell executable. Either flag is enough. A shell alone opens a local tab in the default directory. A path alone uses the configured local shell. If Netcatty is already running, the tab opens in that window.
Windows needs an extra `--` so Electron forwards the flags:
```bat
Netcatty.exe -- --open-terminal-shell=pwsh --open-terminal-path="D:\\proj"
```
On macOS, start a new instance with `open`, or run the binary directly:
```bash
open -n -a Netcatty --args --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
/Applications/Netcatty.app/Contents/MacOS/Netcatty --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
```
### Scoop (Windows x64, community-maintained) ### Scoop (Windows x64, community-maintained)
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases: With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
-1
View File
@@ -130,7 +130,6 @@
- **分屏** —— 水平/垂直分割,多任务并行 - **分屏** —— 水平/垂直分割,多任务并行
- **多会话管理** —— 多连接并排处理 - **多会话管理** —— 多连接并排处理
- **内联图片** —— 直接渲染远端程序输出的 Kitty 图形、SIXEL 与 iTerm 内联图片 - **内联图片** —— 直接渲染远端程序输出的 Kitty 图形、SIXEL 与 iTerm 内联图片
- **压缩包快捷填充** —— 在 Linux 会话里,按住 Ctrl/Cmd 点击输出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 文件名,会在光标处填入带绝对路径的解压命令(保留已经输入的文字);按 Enter 才会执行。相对当前目录的文件名需要目录追踪(OSC 7,见工具栏「配置目录追踪」);绝对路径始终可点。Windows、WSL 与 macOS 会话不在范围内。
### 📁 SFTP + 内置编辑器 ### 📁 SFTP + 内置编辑器
- **文件工作流** —— 拖拽上传/下载更直观 - **文件工作流** —— 拖拽上传/下载更直观
-1
View File
@@ -130,7 +130,6 @@
- **分割窗格** —— 水平與垂直分割,方便多工 - **分割窗格** —— 水平與垂直分割,方便多工
- **工作階段管理** —— 多條連線並排處理 - **工作階段管理** —— 多條連線並排處理
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片 - **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
- **壓縮檔捷徑** —— 在 Linux 工作階段的終端機裡,按住 Ctrl/Cmd 點選輸出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 檔名,會在游標處填入含絕對路徑的解壓縮命令(保留已輸入的文字);按 Enter 才會執行。相對於目前目錄的檔名需要目錄追蹤(OSC 7,見工具列「設定目錄追蹤」);絕對路徑一律可點。Windows、WSL 與 macOS 工作階段不支援。
### 📁 SFTP + 內建編輯器 ### 📁 SFTP + 內建編輯器
- **檔案工作流** —— 拖放上傳/下載 - **檔案工作流** —— 拖放上傳/下載
+3 -4
View File
@@ -534,10 +534,9 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
const source = sessions?.find((s: { id: string }) => s.id === sessionId); const source = sessions?.find((s: { id: string }) => s.id === sessionId);
if (!source) return undefined; if (!source) return undefined;
// Freshest cwd: the live value tracked in terminal state (OSC 7 or the // Freshest cwd: the live OSC 7 value tracked in terminal state (the only
// post-command probe, including local shells). lastCwd on the session object // source that reflects `cd`s in a running local terminal — lastCwd is a
// is only the startup snapshot. Falls through to the SSH probe / lastCwd // startup snapshot). Falls through to the SSH probe / lastCwd when absent.
// when absent.
const liveCwd: string | undefined = getSessionRestoreCwd?.(sessionId); const liveCwd: string | undefined = getSessionRestoreCwd?.(sessionId);
const bridge = netcattyBridge?.get?.(); const bridge = netcattyBridge?.get?.();
@@ -15,14 +15,4 @@ test("open-terminal requests wait behind App Lock and resume after unlock", () =
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/); assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
assert.match(handlerSource, /kind: 'open-terminal-path'/); assert.match(handlerSource, /kind: 'open-terminal-path'/);
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/); assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
const processStart = source.indexOf("const _processOpenTerminalPath");
const processSource = source.slice(processStart, handlerStart);
assert.match(processSource, /handleCreateLocalTerminal\(undefined, \{ localStartDir \}\)/);
assert.match(processSource, /resolveShellSetting\(shellSetting, shells\)/);
assert.match(source, /const openTerminalQueueRef = useRef\(createOpenTerminalQueue\(\)\)/);
const enqueueAt = processSource.indexOf("openTerminalQueueRef.current.enqueue(() => {");
const createAt = processSource.indexOf("handleCreateLocalTerminal");
assert.ok(enqueueAt >= 0 && createAt > enqueueAt);
assert.match(processSource, /await ensureDiscoveredShells\(\)/);
}); });
@@ -1,21 +0,0 @@
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\]/);
});
+13 -56
View File
@@ -47,7 +47,6 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSsh
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink'; import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink'; import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts'; import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
import { prepareConnectionHost, temporaryConnectionPrepareOptions } from '../../domain/legacyAlgorithms';
import { resolveHostAuth } from '../../domain/sshAuth'; import { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials'; import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import { import {
@@ -95,7 +94,6 @@ import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider
import { isScriptSnippet } from '../../domain/snippetScript.ts'; import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts'; import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding'; import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { createOpenTerminalQueue } from './openTerminalQueue';
import { useAppStartupEffects } from './useAppStartupEffects'; import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers'; import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
@@ -1449,24 +1447,13 @@ export function AppSideEffects() {
name, name,
), [createWorkspaceFromTargets, resolveEffectiveHost]); ), [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 // Wrapper to connect to host with logging
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => { const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
const liveTerminalSettings = temporaryConnectionSettingsRef.current; if (host.ephemeral) {
const connectionHost = prepareConnectionHost(
host,
temporaryConnectionPrepareOptions(liveTerminalSettings),
);
if (connectionHost.ephemeral) {
setEphemeralHosts((previous) => { setEphemeralHosts((previous) => {
const existingIndex = previous.findIndex((candidate) => candidate.id === connectionHost.id); const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
if (existingIndex < 0) return [...previous, connectionHost]; if (existingIndex < 0) return [...previous, host];
return previous.map((candidate, index) => index === existingIndex ? connectionHost : candidate); return previous.map((candidate, index) => index === existingIndex ? host : candidate);
}); });
} }
const effectiveHostResolver = alreadyEffective const effectiveHostResolver = alreadyEffective
@@ -1476,16 +1463,16 @@ export function AppSideEffects() {
addConnectionLog, addConnectionLog,
connectToHost, connectToHost,
discoveredShells, discoveredShells,
host: connectionHost, host,
identities, identities,
keys, keys,
resolveEffectiveHost: effectiveHostResolver, resolveEffectiveHost: effectiveHostResolver,
resolveHostAuth, resolveHostAuth,
resolveShellSetting, resolveShellSetting,
systemInfoRef, systemInfoRef,
terminalSettings: liveTerminalSettings, terminalSettings,
}), connectionHost, hidden); }), host, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]); }, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => { const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host); const sessionId = handleConnectToHost(host);
@@ -1570,7 +1557,7 @@ export function AppSideEffects() {
| { kind: 'ssh'; payload: DeepLinkPayload } | { kind: 'ssh'; payload: DeepLinkPayload }
| { kind: 'telnet'; payload: DeepLinkPayload } | { kind: 'telnet'; payload: DeepLinkPayload }
| { kind: 'jms'; payload: { url?: string } } | { kind: 'jms'; payload: { url?: string } }
| { kind: 'open-terminal-path'; payload: { path?: string; shell?: string } } | { kind: 'open-terminal-path'; payload: { path?: string } }
>>([]); >>([]);
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => { const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
@@ -1734,44 +1721,14 @@ export function AppSideEffects() {
}); });
}, [isPeerSessionWindow]); }, [isPeerSessionWindow]);
// Shell discovery is part of this queue. A later path-only request, including const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
// one drained after App Lock, cannot create its tab ahead of an in-flight shell.
const openTerminalQueueRef = useRef(createOpenTerminalQueue());
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
startupLaunchIntentReceivedRef.current = true; startupLaunchIntentReceivedRef.current = true;
const localStartDir = typeof payload?.path === 'string' ? payload.path : ''; const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
const shellSetting = typeof payload?.shell === 'string' ? payload.shell.trim() : ''; if (!localStartDir.trim()) return;
void openTerminalQueueRef.current.enqueue(() => { handleCreateLocalTerminal(undefined, { localStartDir });
if (!shellSetting) {
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
return;
}
return (async () => {
const shells = await ensureDiscoveredShells();
const resolved = resolveShellSetting(shellSetting, shells);
if (!resolved?.command) {
if (!localStartDir.trim()) return;
handleCreateLocalTerminal(undefined, { localStartDir });
return;
}
const matchedShell = shells.find((shell) => shell.id === shellSetting);
handleCreateLocalTerminal(
{
command: resolved.command,
args: resolved.args,
name: matchedShell?.name,
icon: matchedShell?.icon,
},
localStartDir.trim() ? { localStartDir } : undefined,
);
})();
});
}); });
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => { const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) { if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
pendingDeepLinksWhileLockedRef.current.push({ pendingDeepLinksWhileLockedRef.current.push({
kind: 'open-terminal-path', kind: 'open-terminal-path',
-12
View File
@@ -22,7 +22,6 @@ import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label'; import { Label } from '../../components/ui/label';
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary'; import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
import { toast } from '../../components/ui/toast'; import { toast } from '../../components/ui/toast';
import { openQuickConnectFromInput } from '../quickConnectBridge';
import { AppHostTreeLayer } from './AppHostTreeLayer'; import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer'; import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost'; import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
@@ -377,14 +376,6 @@ function AppViewInner({ domains }: AppViewProps) {
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir); appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]); }, [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' const isPeerSessionWindow = typeof window !== 'undefined'
&& window.location.hash.startsWith('#/session-window'); && window.location.hash.startsWith('#/session-window');
const externalMcpToggle = useExternalMcpToggleState(); const externalMcpToggle = useExternalMcpToggleState();
@@ -939,7 +930,6 @@ function AppViewInner({ domains }: AppViewProps) {
showSftpTab={showSftpTab} showSftpTab={showSftpTab}
onQueryChange={setQuickSearch} onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck} onSelect={handleHostConnectWithProtocolCheck}
onQuickConnect={openQuickConnectFromInput}
onEditHost={(host) => { onEditHost={(host) => {
setIsQuickSwitcherOpen(false); setIsQuickSwitcherOpen(false);
setQuickSearch(''); setQuickSearch('');
@@ -1032,8 +1022,6 @@ function AppViewInner({ domains }: AppViewProps) {
request={keyboardInteractiveQueue[0] || null} request={keyboardInteractiveQueue[0] || null}
onSubmit={handleKeyboardInteractiveSubmit} onSubmit={handleKeyboardInteractiveSubmit}
onCancel={handleKeyboardInteractiveCancel} onCancel={handleKeyboardInteractiveCancel}
canFillTotp={Boolean(pendingTotpUri)}
onFillTotp={handleFillTotp}
/> />
{/* Indicator when more 2FA requests are pending */} {/* Indicator when more 2FA requests are pending */}
{keyboardInteractiveQueue.length > 1 && ( {keyboardInteractiveQueue.length > 1 && (
-54
View File
@@ -1,54 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createOpenTerminalQueue } from './openTerminalQueue';
test('idle open-terminal work stays in call order on the caller turn', () => {
const queue = createOpenTerminalQueue();
const order: string[] = [];
queue.enqueue(() => { order.push('path-a'); });
queue.enqueue(() => { order.push('path-b'); });
assert.deepEqual(order, ['path-a', 'path-b']);
});
test('a path-only open waits for an in-flight shell discovery', async () => {
const queue = createOpenTerminalQueue();
const order: string[] = [];
let releaseDiscovery: () => void = () => {};
const discovery = new Promise<void>((resolve) => {
releaseDiscovery = resolve;
});
const shellRequest = queue.enqueue(async () => {
order.push('shell');
await discovery;
order.push('shell-tab');
});
const pathRequest = queue.enqueue(() => {
order.push('path-tab');
});
assert.deepEqual(order, ['shell']);
releaseDiscovery();
await shellRequest;
await pathRequest;
assert.deepEqual(order, ['shell', 'shell-tab', 'path-tab']);
});
test('a failed open-terminal request does not block the next one', async () => {
const queue = createOpenTerminalQueue();
const order: string[] = [];
const failed = queue.enqueue(async () => {
order.push('shell');
throw new Error('discovery failed');
});
const next = queue.enqueue(() => {
order.push('path-tab');
});
await assert.rejects(failed, /discovery failed/);
await next;
assert.deepEqual(order, ['shell', 'path-tab']);
});
-50
View File
@@ -1,50 +0,0 @@
type OpenTerminalTask = () => Promise<void> | void;
function isPromise(value: Promise<void> | void): value is Promise<void> {
return Boolean(value) && typeof (value as Promise<void>).then === 'function';
}
/**
* Ordered launcher queue. An idle queue runs the task in the caller turn so a
* path-only open still creates its tab synchronously. A task that awaits (shell
* discovery) holds the tail, and later requests cannot pass it.
*/
export function createOpenTerminalQueue() {
let tail: Promise<void> = Promise.resolve();
let depth = 0;
return {
enqueue(task: OpenTerminalTask): Promise<void> {
depth += 1;
let release: () => void;
const finished = new Promise<void>((resolve) => {
release = () => {
depth -= 1;
resolve();
};
});
const previous = tail;
tail = finished;
const run = (): Promise<void> => {
try {
const result = task();
if (isPromise(result)) {
return result.then(release, (error: unknown) => {
release();
throw error;
});
}
release();
return Promise.resolve();
} catch (error) {
release();
throw error;
}
};
if (depth === 1) return run();
return previous.then(run, run);
},
};
}
-3
View File
@@ -554,9 +554,6 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
hostname: request.hostname, hostname: request.hostname,
savedPassword: request.savedPassword, savedPassword: request.savedPassword,
allowSavePassword: request.allowSavePassword !== false, allowSavePassword: request.allowSavePassword !== false,
...(typeof request.totpUri === "string" && request.totpUri && !request.totpUri.startsWith("enc:v1:")
? { totpUri: request.totpUri }
: {}),
}]); }]);
}); });
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => { const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
-64
View File
@@ -289,14 +289,6 @@ export const enAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode', 'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
'ai.opencode.check': 'Check', 'ai.opencode.check': 'Check',
'ai.opencode.resetPath': 'Reset', '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 Code (Xiaomi MiMo Code CLI — OpenCode fork)
'ai.mimo.title': 'MiMo Code', 'ai.mimo.title': 'MiMo Code',
@@ -364,14 +356,6 @@ export const enAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': 'min', 'ai.externalMcp.idleTimeout.minutes': 'min',
'ai.externalMcp.focusOnHostOpen': 'Focus window on host_open', 'ai.externalMcp.focusOnHostOpen': 'Focus window on host_open',
'ai.externalMcp.focusOnHostOpen.description': 'When an MCP client opens a host, bring the main window to the foreground. Turn off to keep working without interruption.', 'ai.externalMcp.focusOnHostOpen.description': 'When an MCP client opens a host, bring the main window to the foreground. Turn off to keep working without interruption.',
'ai.externalMcp.approvalAlert': 'Approval alerts',
'ai.externalMcp.approvalAlert.description': 'When an external MCP client asks for confirmation, show a system notification. While Netcatty is unfocused, the taskbar or Dock asks for attention. The notification uses the system sound.',
'ai.externalMcp.approvalAlert.off': 'Off',
'ai.externalMcp.approvalAlert.unfocused': 'When unfocused',
'ai.externalMcp.approvalAlert.always': 'Always',
'ai.externalMcp.approvalAlert.notificationTitle': 'External MCP approval',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} is waiting for approval',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} on {host} is waiting for approval',
'ai.externalMcp.silentSessions': 'Silent MCP sessions', 'ai.externalMcp.silentSessions': 'Silent MCP sessions',
'ai.externalMcp.silentSessions.description': 'Sessions opened by AI stay out of your tab bar and are not restored after restart. View them anytime from the tray panel.', 'ai.externalMcp.silentSessions.description': 'Sessions opened by AI stay out of your tab bar and are not restored after restart. View them anytime from the tray panel.',
'ai.externalMcp.sessionIdleTimeout': 'Opened session idle timeout', 'ai.externalMcp.sessionIdleTimeout': 'Opened session idle timeout',
@@ -384,54 +368,6 @@ export const enAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'External MCP help', 'ai.externalMcp.help.ariaLabel': 'External MCP help',
'ai.externalMcp.security': 'Security', '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.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': 'Current permission mode: {mode}',
'ai.externalMcp.permissionMode.label': 'Write permission 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.', '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.',
+5 -26
View File
@@ -413,7 +413,7 @@ export const enCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Dark palette', 'settings.appearance.themeColor.dark': 'Dark palette',
'settings.appearance.customCss': 'Custom CSS', 'settings.appearance.customCss': 'Custom CSS',
'settings.appearance.customCss.desc': 'settings.appearance.customCss.desc':
'Add custom CSS to personalize the app appearance. Changes apply immediately. Selected top tabs (`.top-tab[data-active="true"]`) read `--top-tab-active-bg`, `--top-tab-active-fg`, and `--top-tab-active-border`. Leave them unset to keep the current look.', 'Add custom CSS to personalize the app appearance. Changes apply immediately.',
'settings.appearance.customCss.help.ariaLabel': 'Custom CSS help', 'settings.appearance.customCss.help.ariaLabel': 'Custom CSS help',
'settings.appearance.customCss.help.title': 'Custom CSS guide', 'settings.appearance.customCss.help.title': 'Custom CSS guide',
'settings.appearance.customCss.help.body': `Major UI regions expose a \`data-section\` attribute you can target. Changes apply as soon as you edit the CSS. 'settings.appearance.customCss.help.body': `Major UI regions expose a \`data-section\` attribute you can target. Changes apply as soon as you edit the CSS.
@@ -440,22 +440,6 @@ Because many styles come from Tailwind utilities, you often need \`!important\`
- \`top-tabs-host-tree-toggle\` - \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\` - \`top-tabs-quick-switcher-toggle\`
Selected top tabs use the class \`top-tab\` and \`data-active="true"\`. Set these variables to recolor the selected tab. Leave them unset to keep the current look. They apply without \`!important\`.
- \`--top-tab-active-bg\` — selected fill (defaults to the current tab background)
- \`--top-tab-active-fg\` — selected label color (defaults to the current tab text)
- \`--top-tab-active-border\` — 1px inset outline (defaults to transparent, so there is no border)
The Vaults tab stays flat and does not use the fill.
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### Vault ### Vault
- \`vault-view\` - \`vault-view\`
@@ -549,7 +533,7 @@ Highlight the focused split pane:
\`\`\` \`\`\`
`, `,
'settings.appearance.customCss.placeholder': 'settings.appearance.customCss.placeholder':
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Selected top tab. Leave these unset to keep the current look. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */', '/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Hide the host-list toggle in the top tab bar */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Hide the plus button that opens the quick switcher */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Border around the SFTP / side panel (does not linger after closing) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Change the whole side panel background, not only the top tabs */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Style selected SFTP file rows */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
'settings.appearance.language': 'Language', 'settings.appearance.language': 'Language',
'settings.appearance.language.desc': 'Choose the UI language', 'settings.appearance.language.desc': 'Choose the UI language',
'settings.appearance.uiFont': 'Interface Font', 'settings.appearance.uiFont': 'Interface Font',
@@ -728,7 +712,7 @@ Highlight the focused split pane:
'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.', 'Send configured text instead of normal Enter when pressing Shift+Enter in the terminal.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send', 'settings.terminal.behavior.shiftEnterNewlineText': 'Text to send',
'settings.terminal.behavior.shiftEnterNewlineText.desc': 'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash. Defaults to \\e\\r (Escape + Enter, the legacy Alt+Enter encoding), which most TUI CLIs treat as a newline.', 'Use \\n for newline, \\t for tab, \\e for Escape, and \\\\ for backslash.',
'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode', 'settings.terminal.behavior.shiftEnterForceText': 'Send text in Win32 input mode',
'settings.terminal.behavior.shiftEnterForceText.desc': 'settings.terminal.behavior.shiftEnterForceText.desc':
'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.', 'Also send the text above when a local Windows ConPTY session negotiates Win32 input mode. Needed for Node/Bun CLIs (Claude Code, CodeBuddy) that cannot read modifier keys from console input records.',
@@ -836,10 +820,6 @@ Highlight the focused split pane:
'settings.terminal.section.connection': 'Connection', 'settings.terminal.section.connection': 'Connection',
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys', '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.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': '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.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', 'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
@@ -864,8 +844,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.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': 'tmux session refresh',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.', 'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Container list refresh', 'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.', 'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh', 'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.', 'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
'settings.terminal.serverStats.show': 'Show Server Stats', 'settings.terminal.serverStats.show': 'Show Server Stats',
@@ -1180,7 +1160,6 @@ Highlight the focused split pane:
'vault.hosts.moveSelected': 'Move to group ({count})', '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.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.moveMultiple.success': 'Moved {count} hosts to {group}',
'vault.hosts.dragSelectionCount': '{count} hosts',
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}', 'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
'vault.hosts.errors.nameRequired': 'Host name is required.', 'vault.hosts.errors.nameRequired': 'Host name is required.',
'vault.hosts.empty.title': 'Set up your hosts', 'vault.hosts.empty.title': 'Set up your hosts',
+1 -36
View File
@@ -26,11 +26,6 @@ export const enSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Failed to load containers', 'systemManager.errors.loadDocker': 'Failed to load containers',
'systemManager.errors.loadDockerStats': 'Failed to load container stats', 'systemManager.errors.loadDockerStats': 'Failed to load container stats',
'systemManager.errors.loadDockerImages': 'Failed to load images', '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.loadOverview': 'Failed to load system overview',
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats', 'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
'systemManager.errors.loadPorts': 'Failed to load listening ports', 'systemManager.errors.loadPorts': 'Failed to load listening ports',
@@ -137,7 +132,7 @@ export const enSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)', 'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
'systemManager.tmux.noWindows': 'No windows', 'systemManager.tmux.noWindows': 'No windows',
'systemManager.tmux.unavailable': 'tmux is not available on this host', 'systemManager.tmux.unavailable': 'tmux is not available on this host',
'systemManager.docker.unavailable': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host', 'systemManager.docker.unavailable': 'Docker is not available on this host',
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none', 'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
'systemManager.tmux.lastCommand': 'last command: {{command}}', 'systemManager.tmux.lastCommand': 'last command: {{command}}',
'systemManager.tmux.noPanes': 'No panes', 'systemManager.tmux.noPanes': 'No panes',
@@ -199,36 +194,6 @@ export const enSystemManagerMessages: Messages = {
'systemManager.docker.imagesMeta': '{{count}} image(s)', 'systemManager.docker.imagesMeta': '{{count}} image(s)',
'systemManager.docker.start': 'Start', 'systemManager.docker.start': 'Start',
'systemManager.docker.stop': 'Stop', '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.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
'systemManager.ports.loading': 'Loading listening ports…', 'systemManager.ports.loading': 'Loading listening ports…',
-4
View File
@@ -109,8 +109,6 @@ export const enTerminalMessages: Messages = {
'terminal.serverStats.cpu': 'CPU Usage', 'terminal.serverStats.cpu': 'CPU Usage',
'terminal.serverStats.cpuCores': 'CPU Core Usage', 'terminal.serverStats.cpuCores': 'CPU Core Usage',
'terminal.serverStats.gpu': 'GPU Usage', 'terminal.serverStats.gpu': 'GPU Usage',
'terminal.serverStats.gpuDevices': 'GPU Device Usage',
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
'terminal.serverStats.memory': 'Memory Usage', 'terminal.serverStats.memory': 'Memory Usage',
'terminal.serverStats.memoryDetails': 'Memory Details', 'terminal.serverStats.memoryDetails': 'Memory Details',
'terminal.serverStats.memUsed': 'Used', 'terminal.serverStats.memUsed': 'Used',
@@ -912,13 +910,11 @@ export const enTerminalMessages: Messages = {
'keyboard.interactive.desc': 'The server requires additional authentication.', 'keyboard.interactive.desc': 'The server requires additional authentication.',
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.', 'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
'keyboard.interactive.response': 'Response', 'keyboard.interactive.response': 'Response',
'keyboard.interactive.verificationCode': 'Verification code',
'keyboard.interactive.enterCode': 'Enter verification code', 'keyboard.interactive.enterCode': 'Enter verification code',
'keyboard.interactive.enterResponse': 'Enter response', 'keyboard.interactive.enterResponse': 'Enter response',
'keyboard.interactive.submit': 'Submit', 'keyboard.interactive.submit': 'Submit',
'keyboard.interactive.verifying': 'Verifying...', 'keyboard.interactive.verifying': 'Verifying...',
'keyboard.interactive.savePassword': 'Save password', 'keyboard.interactive.savePassword': 'Save password',
'keyboard.interactive.fillTotp': 'Fill verification code',
// Passphrase Modal for encrypted SSH keys // Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH Key Passphrase', 'passphrase.title': 'SSH Key Passphrase',
-33
View File
@@ -668,10 +668,6 @@ export const enVaultMessages: Messages = {
// Quick Switcher // Quick Switcher
'qs.search.placeholder': 'Search hosts or tabs', '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.jumpTo': 'Jump To',
'qs.localTerminal': 'Local Terminal', 'qs.localTerminal': 'Local Terminal',
'qs.localShells': 'Local Shells', 'qs.localShells': 'Local Shells',
@@ -880,35 +876,6 @@ export const enVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': 'Use the selected SSH certificate, with the saved password as a fallback when present.', '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': 'Prefer interactive auth',
'hostDetails.auth.mfaFirst.desc': 'For servers that require password and MFA through keyboard-interactive.', '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.auth.credentialRequired': 'Select a key or certificate before saving this login method.',
'hostDetails.identity.suggestions': 'Identities', 'hostDetails.identity.suggestions': 'Identities',
'hostDetails.identity.missing': 'Identity not found', 'hostDetails.identity.missing': 'Identity not found',
-56
View File
@@ -347,14 +347,6 @@ export const esAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': 'min', 'ai.externalMcp.idleTimeout.minutes': 'min',
'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open', 'ai.externalMcp.focusOnHostOpen': 'Enfocar ventana en host_open',
'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.', 'ai.externalMcp.focusOnHostOpen.description': 'Cuando un cliente de MCP abre un host, trae la ventana principal al frente. Apágalo para seguir trabajando sin interrupciones.',
'ai.externalMcp.approvalAlert': 'Alertas de aprobación',
'ai.externalMcp.approvalAlert.description': 'Cuando un cliente MCP externo pide confirmación, muestra una notificación del sistema. Si Netcatty no tiene el foco, la barra de tareas o el Dock pide atención. La notificación usa el sonido del sistema.',
'ai.externalMcp.approvalAlert.off': 'Desactivado',
'ai.externalMcp.approvalAlert.unfocused': 'Si no tiene el foco',
'ai.externalMcp.approvalAlert.always': 'Siempre',
'ai.externalMcp.approvalAlert.notificationTitle': 'Aprobación de MCP externo',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} espera aprobación',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} en {host} espera aprobación',
'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas', 'ai.externalMcp.silentSessions': 'Sesiones MCP silenciosas',
'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.', 'ai.externalMcp.silentSessions.description': 'Las sesiones abiertas por la IA se mantienen fuera de tu barra de pestañas y no se restauran tras reiniciar. Puedes verlas en cualquier momento desde el panel de la bandeja.',
'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta', 'ai.externalMcp.sessionIdleTimeout': 'Tiempo de inactividad de la sesión abierta',
@@ -367,54 +359,6 @@ export const esAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo', 'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
'ai.externalMcp.security': 'Seguridad', '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.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': 'Modo de permisos actual: {mode}',
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura', '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.', '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.',
+5 -26
View File
@@ -328,7 +328,7 @@ export const esCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Paleta oscura', 'settings.appearance.themeColor.dark': 'Paleta oscura',
'settings.appearance.customCss': 'CSS personalizado', 'settings.appearance.customCss': 'CSS personalizado',
'settings.appearance.customCss.desc': 'settings.appearance.customCss.desc':
'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato. Las pestañas superiores seleccionadas (`.top-tab[data-active="true"]`) leen `--top-tab-active-bg`, `--top-tab-active-fg` y `--top-tab-active-border`. Si no las defines, se conserva el aspecto actual.', 'Agrega CSS personalizado para personalizar la apariencia de la aplicación. Los cambios se aplican de inmediato.',
'settings.appearance.customCss.help.ariaLabel': 'Ayuda de CSS personalizado', 'settings.appearance.customCss.help.ariaLabel': 'Ayuda de CSS personalizado',
'settings.appearance.customCss.help.title': 'Guía de CSS personalizado', 'settings.appearance.customCss.help.title': 'Guía de CSS personalizado',
'settings.appearance.customCss.help.body': `Las principales regiones de la interfaz exponen un atributo \`data-section\` que puedes usar como destino. Los cambios se aplican en cuanto editas el CSS. 'settings.appearance.customCss.help.body': `Las principales regiones de la interfaz exponen un atributo \`data-section\` que puedes usar como destino. Los cambios se aplican en cuanto editas el CSS.
@@ -355,22 +355,6 @@ Como muchos estilos provienen de utilidades de Tailwind, a menudo necesitas \`!i
- \`top-tabs-host-tree-toggle\` - \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\` - \`top-tabs-quick-switcher-toggle\`
Las pestañas superiores seleccionadas usan la clase \`top-tab\` y \`data-active="true"\`. Define estas variables para cambiar el color de la pestaña seleccionada. Si no las defines, se conserva el aspecto actual. Se aplican sin \`!important\`.
- \`--top-tab-active-bg\` — relleno seleccionado (por defecto, el fondo actual de la pestaña)
- \`--top-tab-active-fg\` — color del texto seleccionado (por defecto, el texto actual)
- \`--top-tab-active-border\` — contorno interior de 1px (transparente por defecto, así que no hay borde)
La pestaña Vaults permanece plana y no usa el relleno.
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### Vault ### Vault
- \`vault-view\` - \`vault-view\`
@@ -464,7 +448,7 @@ Resalta el panel dividido enfocado:
\`\`\` \`\`\`
`, `,
'settings.appearance.customCss.placeholder': 'settings.appearance.customCss.placeholder':
'/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Pestaña superior seleccionada. Si no defines estas variables, se conserva el aspecto actual. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */', '/* Ejemplos: usa !important para superar la especificidad de las utilidades de Tailwind */\n\n/* Oculta el botón de lista de hosts en la barra de pestañas superior */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Oculta el botón + que abre el selector rápido */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Borde alrededor del panel SFTP / lateral (no persiste tras cerrarlo) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Cambia el fondo de todo el panel lateral, no solo las pestañas superiores */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Estiliza las filas de archivos SFTP seleccionadas */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Divisores de división más gruesos */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Resalta el panel dividido enfocado */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* O usa Configuración → Terminal → Indicador de foco del espacio de trabajo → Borde en el panel enfocado */',
'settings.appearance.language': 'Idioma', 'settings.appearance.language': 'Idioma',
'settings.appearance.language.desc': 'Elige el idioma de la interfaz', 'settings.appearance.language.desc': 'Elige el idioma de la interfaz',
'settings.appearance.uiFont': 'Fuente de la interfaz', 'settings.appearance.uiFont': 'Fuente de la interfaz',
@@ -643,7 +627,7 @@ Resalta el panel dividido enfocado:
'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.', 'Envía el texto configurado en lugar del Enter normal al presionar Shift+Enter en la terminal.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar', 'settings.terminal.behavior.shiftEnterNewlineText': 'Texto a enviar',
'settings.terminal.behavior.shiftEnterNewlineText.desc': 'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida. Por defecto \\e\\r (Escape + Enter, la codificación clásica de Alt+Enter), que la mayoría de las CLI TUI interpretan como nueva línea.', 'Usa \\n para nueva línea, \\t para tabulación, \\e para Escape y \\\\ para barra invertida.',
'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32', 'settings.terminal.behavior.shiftEnterForceText': 'Enviar texto también en modo de entrada Win32',
'settings.terminal.behavior.shiftEnterForceText.desc': 'settings.terminal.behavior.shiftEnterForceText.desc':
'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.', 'Envía también el texto anterior cuando una sesión ConPTY local de Windows negocia el modo de entrada Win32. Necesario para CLI escritas en Node/Bun (Claude Code, CodeBuddy) que no pueden leer modificadores desde los registros de entrada de la consola.',
@@ -751,10 +735,6 @@ Resalta el panel dividido enfocado:
'settings.terminal.section.connection': 'Conexión', 'settings.terminal.section.connection': 'Conexión',
'settings.terminal.connection.verifyHostKeys': 'Verificar las claves de host SSH', '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.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': '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.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', 'settings.terminal.connection.keepaliveInterval': 'Intervalo de keepalive',
@@ -779,8 +759,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.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': 'Actualización de sesiones tmux',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Con qué frecuencia actualizar la lista 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', 'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores Docker',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.', 'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de contenedores Docker.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Actualización de estadísticas Docker', '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.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', 'settings.terminal.serverStats.show': 'Mostrar estadísticas del servidor',
@@ -1139,7 +1119,6 @@ Resalta el panel dividido enfocado:
'vault.hosts.moveSelected': 'Mover al grupo ({count})', '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.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.moveMultiple.success': 'Se movieron {count} hosts a {group}',
'vault.hosts.dragSelectionCount': '{count} hosts',
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}', 'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.', 'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
'vault.hosts.empty.title': 'Configura tus hosts', 'vault.hosts.empty.title': 'Configura tus hosts',
+1 -19
View File
@@ -26,8 +26,6 @@ export const esSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores', 'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de 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.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.loadOverview': 'No se pudo cargar el resumen del sistema',
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU', 'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha', 'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
@@ -134,7 +132,7 @@ export const esSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)', 'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
'systemManager.tmux.noWindows': 'No hay ventanas', 'systemManager.tmux.noWindows': 'No hay ventanas',
'systemManager.tmux.unavailable': 'tmux no está disponible en este host', 'systemManager.tmux.unavailable': 'tmux 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.docker.unavailable': 'Docker no está disponible en este host',
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna', 'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
'systemManager.tmux.lastCommand': 'último comando: {{command}}', 'systemManager.tmux.lastCommand': 'último comando: {{command}}',
'systemManager.tmux.noPanes': 'No hay paneles', 'systemManager.tmux.noPanes': 'No hay paneles',
@@ -196,22 +194,6 @@ export const esSystemManagerMessages: Messages = {
'systemManager.docker.imagesMeta': '{{count}} imagen(es)', 'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
'systemManager.docker.start': 'Iniciar', 'systemManager.docker.start': 'Iniciar',
'systemManager.docker.stop': 'Detener', '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.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…', 'systemManager.ports.loading': 'Cargando puertos en escucha…',
-4
View File
@@ -109,8 +109,6 @@ export const esTerminalMessages: Messages = {
'terminal.serverStats.cpu': 'Uso de CPU', 'terminal.serverStats.cpu': 'Uso de CPU',
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU', 'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
'terminal.serverStats.gpu': 'Uso de GPU', 'terminal.serverStats.gpu': 'Uso de GPU',
'terminal.serverStats.gpuDevices': 'Uso de cada GPU',
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
'terminal.serverStats.memory': 'Uso de memoria', 'terminal.serverStats.memory': 'Uso de memoria',
'terminal.serverStats.memoryDetails': 'Detalles de memoria', 'terminal.serverStats.memoryDetails': 'Detalles de memoria',
'terminal.serverStats.memUsed': 'Usada', 'terminal.serverStats.memUsed': 'Usada',
@@ -874,13 +872,11 @@ export const esTerminalMessages: Messages = {
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.', 'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.', 'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
'keyboard.interactive.response': 'Respuesta', 'keyboard.interactive.response': 'Respuesta',
'keyboard.interactive.verificationCode': 'Código de verificación',
'keyboard.interactive.enterCode': 'Ingresa el código de verificación', 'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
'keyboard.interactive.enterResponse': 'Ingresa la respuesta', 'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
'keyboard.interactive.submit': 'Enviar', 'keyboard.interactive.submit': 'Enviar',
'keyboard.interactive.verifying': 'Verificando...', 'keyboard.interactive.verifying': 'Verificando...',
'keyboard.interactive.savePassword': 'Guardar contraseña', 'keyboard.interactive.savePassword': 'Guardar contraseña',
'keyboard.interactive.fillTotp': 'Rellenar código de verificación',
// Passphrase Modal for encrypted SSH keys // Passphrase Modal for encrypted SSH keys
'passphrase.title': 'Frase de contraseña de la clave SSH', 'passphrase.title': 'Frase de contraseña de la clave SSH',
-33
View File
@@ -668,10 +668,6 @@ export const esVaultMessages: Messages = {
// Quick Switcher // Quick Switcher
'qs.search.placeholder': 'Buscar hosts o pestañas', '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.jumpTo': 'Ir a',
'qs.localTerminal': 'Terminal local', 'qs.localTerminal': 'Terminal local',
'qs.localShells': 'Shells locales', 'qs.localShells': 'Shells locales',
@@ -870,35 +866,6 @@ 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.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': 'Preferir autenticación interactiva',
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.', '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.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.suggestions': 'Identidades',
'hostDetails.identity.missing': 'Identidad no encontrada', 'hostDetails.identity.missing': 'Identidad no encontrada',
@@ -1,29 +0,0 @@
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.approvalAlert',
'ai.externalMcp.approvalAlert.description',
'ai.externalMcp.approvalAlert.off',
'ai.externalMcp.approvalAlert.unfocused',
'ai.externalMcp.approvalAlert.always',
'ai.externalMcp.approvalAlert.notificationTitle',
'ai.externalMcp.approvalAlert.notificationBody',
'ai.externalMcp.approvalAlert.notificationBodyOnHost',
] as const;
test('External MCP approval alerts are localized in every supported 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 approval alert labels`);
assert.match(messages['ai.externalMcp.approvalAlert.notificationBody'], /\{tool\}/);
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{tool\}/);
assert.match(messages['ai.externalMcp.approvalAlert.notificationBodyOnHost'], /\{host\}/);
}
});
@@ -1,58 +0,0 @@
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"], /鏡像網路/);
});
@@ -1,23 +0,0 @@
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`,
);
}
});
@@ -1,39 +0,0 @@
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);
});
-56
View File
@@ -303,14 +303,6 @@ export const ruAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': 'мин', 'ai.externalMcp.idleTimeout.minutes': 'мин',
'ai.externalMcp.focusOnHostOpen': 'Активировать окно при host_open', 'ai.externalMcp.focusOnHostOpen': 'Активировать окно при host_open',
'ai.externalMcp.focusOnHostOpen.description': 'Когда MCP-клиент открывает хост, выводить главное окно на передний план. Отключите, чтобы работать без прерываний.', 'ai.externalMcp.focusOnHostOpen.description': 'Когда MCP-клиент открывает хост, выводить главное окно на передний план. Отключите, чтобы работать без прерываний.',
'ai.externalMcp.approvalAlert': 'Оповещения об одобрении',
'ai.externalMcp.approvalAlert.description': 'Когда внешний MCP-клиент запрашивает подтверждение, показать системное уведомление. Пока Netcatty не в фокусе, панель задач или Dock привлекают внимание. Уведомление использует системный звук.',
'ai.externalMcp.approvalAlert.off': 'Выкл.',
'ai.externalMcp.approvalAlert.unfocused': 'Без фокуса',
'ai.externalMcp.approvalAlert.always': 'Всегда',
'ai.externalMcp.approvalAlert.notificationTitle': 'Одобрение External MCP',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} ожидает одобрения',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{tool} на {host} ожидает одобрения',
'ai.externalMcp.silentSessions': 'Бесшумные сеансы MCP', 'ai.externalMcp.silentSessions': 'Бесшумные сеансы MCP',
'ai.externalMcp.silentSessions.description': 'Сеансы, открытые ИИ, не отображаются на панели вкладок и не восстанавливаются после перезапуска. Их можно посмотреть в любой момент через панель в трее.', 'ai.externalMcp.silentSessions.description': 'Сеансы, открытые ИИ, не отображаются на панели вкладок и не восстанавливаются после перезапуска. Их можно посмотреть в любой момент через панель в трее.',
'ai.externalMcp.sessionIdleTimeout': 'Тайм-аут открытого сеанса', 'ai.externalMcp.sessionIdleTimeout': 'Тайм-аут открытого сеанса',
@@ -323,54 +315,6 @@ export const ruAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': 'Справка External MCP', 'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
'ai.externalMcp.security': 'Безопасность', 'ai.externalMcp.security': 'Безопасность',
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.', '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': 'Текущий режим разрешений: {mode}',
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись', 'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.', 'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
+5 -25
View File
@@ -397,7 +397,7 @@ export const ruCoreMessages: Messages = {
'settings.appearance.themeColor.dark': 'Палитра тёмной темы', 'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
'settings.appearance.customCss': 'Пользовательский CSS', 'settings.appearance.customCss': 'Пользовательский CSS',
'settings.appearance.customCss.desc': 'settings.appearance.customCss.desc':
'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу. Выбранные верхние вкладки (`.top-tab[data-active="true"]`) читают `--top-tab-active-bg`, `--top-tab-active-fg` и `--top-tab-active-border`. Не задавайте их, чтобы сохранить текущий вид.', 'Добавьте пользовательский CSS, чтобы настроить внешний вид. Изменения применяются сразу.',
'settings.appearance.customCss.help.ariaLabel': 'Справка по пользовательскому CSS', 'settings.appearance.customCss.help.ariaLabel': 'Справка по пользовательскому CSS',
'settings.appearance.customCss.help.title': 'Руководство по пользовательскому CSS', 'settings.appearance.customCss.help.title': 'Руководство по пользовательскому CSS',
'settings.appearance.customCss.help.body': `Основные области интерфейса помечены атрибутом \`data-section\` для CSS-селекторов. Изменения применяются сразу после правки. 'settings.appearance.customCss.help.body': `Основные области интерфейса помечены атрибутом \`data-section\` для CSS-селекторов. Изменения применяются сразу после правки.
@@ -423,22 +423,6 @@ export const ruCoreMessages: Messages = {
- \`top-tabs-host-tree-toggle\` - \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\` - \`top-tabs-quick-switcher-toggle\`
У выбранных верхних вкладок есть класс \`top-tab\` и атрибут \`data-active="true"\`. Задайте эти переменные, чтобы перекрасить выбранную вкладку. Не задавайте их, чтобы сохранить текущий вид. \`!important\` не нужен.
- \`--top-tab-active-bg\` — заливка выбранной вкладки (по умолчанию текущий фон вкладки)
- \`--top-tab-active-fg\` — цвет подписи (по умолчанию текущий цвет текста)
- \`--top-tab-active-border\` — внутренняя обводка 1px (по умолчанию прозрачная, то есть без рамки)
Вкладка Vaults остаётся плоской и не использует заливку.
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### Хранилище ### Хранилище
- \`vault-view\` - \`vault-view\`
@@ -518,7 +502,7 @@ export const ruCoreMessages: Messages = {
\`\`\` \`\`\`
`, `,
'settings.appearance.customCss.placeholder': 'settings.appearance.customCss.placeholder':
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Выбранная верхняя вкладка. Не задавайте переменные, чтобы сохранить текущий вид. */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */', '/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Скрыть переключатель списка хостов в верхней панели вкладок */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* Скрыть кнопку плюса, открывающую быстрый переключатель */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* Рамка вокруг боковой панели SFTP (не остаётся после закрытия) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Изменить фон всей боковой панели, а не только верхних вкладок */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* Настроить выбранные строки SFTP */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
'settings.appearance.language': 'Язык', 'settings.appearance.language': 'Язык',
'settings.appearance.language.desc': 'Выберите язык интерфейса', 'settings.appearance.language.desc': 'Выберите язык интерфейса',
'settings.appearance.uiFont': 'Шрифт интерфейса', 'settings.appearance.uiFont': 'Шрифт интерфейса',
@@ -679,7 +663,7 @@ export const ruCoreMessages: Messages = {
'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.', 'Отправлять настроенный текст вместо обычного Enter при нажатии Shift+Enter в терминале.',
'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст', 'settings.terminal.behavior.shiftEnterNewlineText': 'Отправляемый текст',
'settings.terminal.behavior.shiftEnterNewlineText.desc': 'settings.terminal.behavior.shiftEnterNewlineText.desc':
'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты. По умолчанию \\e\\r (Escape + Enter, классическая кодировка Alt+Enter), которую большинство TUI-CLI распознают как перевод строки.', 'Используйте \\n для перевода строки, \\t для табуляции, \\e для Escape и \\\\ для обратной косой черты.',
'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32', 'settings.terminal.behavior.shiftEnterForceText': 'Отправлять текст и в режиме ввода Win32',
'settings.terminal.behavior.shiftEnterForceText.desc': 'settings.terminal.behavior.shiftEnterForceText.desc':
'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.', 'Отправлять указанный выше текст даже когда локальная сессия Windows ConPTY согласует режим ввода Win32. Нужно для CLI на Node/Bun (Claude Code, CodeBuddy), которые не читают модификаторы из записей ввода консоли.',
@@ -791,10 +775,6 @@ export const ruCoreMessages: Messages = {
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.', 'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
'settings.terminal.localShellSidePanel.pane.files': 'Файлы', 'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
'settings.terminal.section.connection': 'Подключение', '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': 'Интервал keepalive',
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.', 'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive', 'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
@@ -817,8 +797,8 @@ export const ruCoreMessages: Messages = {
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.', 'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux', 'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.', 'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров', 'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.', 'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker', 'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.', 'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
'settings.terminal.serverStats.show': 'Показывать статистику сервера', 'settings.terminal.serverStats.show': 'Показывать статистику сервера',
+1 -19
View File
@@ -26,8 +26,6 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры', 'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров', 'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы', 'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
'systemManager.errors.loadKubernetes': 'Не удалось загрузить ресурсы Kubernetes',
'systemManager.errors.loadPods': 'Не удалось загрузить поды',
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы', 'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU', 'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты', 'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
@@ -134,7 +132,7 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)', 'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
'systemManager.tmux.noWindows': 'Нет окон', 'systemManager.tmux.noWindows': 'Нет окон',
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте', 'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)', 'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул', 'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}', 'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
'systemManager.tmux.noPanes': 'Нет панелей', 'systemManager.tmux.noPanes': 'Нет панелей',
@@ -196,22 +194,6 @@ export const ruSystemManagerMessages: Messages = {
'systemManager.docker.imagesMeta': '{{count}} образов', 'systemManager.docker.imagesMeta': '{{count}} образов',
'systemManager.docker.start': 'Запустить', 'systemManager.docker.start': 'Запустить',
'systemManager.docker.stop': 'Остановить', '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.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
'systemManager.ports.loading': 'Загрузка слушающих портов…', 'systemManager.ports.loading': 'Загрузка слушающих портов…',
-4
View File
@@ -130,8 +130,6 @@ export const ruTerminalMessages: Messages = {
'terminal.serverStats.cpu': 'Использование CPU', 'terminal.serverStats.cpu': 'Использование CPU',
'terminal.serverStats.cpuCores': 'Использование ядер CPU', 'terminal.serverStats.cpuCores': 'Использование ядер CPU',
'terminal.serverStats.gpu': 'Использование GPU', 'terminal.serverStats.gpu': 'Использование GPU',
'terminal.serverStats.gpuDevices': 'Использование видеокарт',
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
'terminal.serverStats.memory': 'Использование памяти', 'terminal.serverStats.memory': 'Использование памяти',
'terminal.serverStats.memoryDetails': 'Сведения о памяти', 'terminal.serverStats.memoryDetails': 'Сведения о памяти',
'terminal.serverStats.memUsed': 'Использовано', 'terminal.serverStats.memUsed': 'Использовано',
@@ -919,13 +917,11 @@ export const ruTerminalMessages: Messages = {
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.', 'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.', 'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
'keyboard.interactive.response': 'Ответ', 'keyboard.interactive.response': 'Ответ',
'keyboard.interactive.verificationCode': 'Код подтверждения',
'keyboard.interactive.enterCode': 'Введите код подтверждения', 'keyboard.interactive.enterCode': 'Введите код подтверждения',
'keyboard.interactive.enterResponse': 'Введите ответ', 'keyboard.interactive.enterResponse': 'Введите ответ',
'keyboard.interactive.submit': 'Отправить', 'keyboard.interactive.submit': 'Отправить',
'keyboard.interactive.verifying': 'Проверка...', 'keyboard.interactive.verifying': 'Проверка...',
'keyboard.interactive.savePassword': 'Сохранить пароль', 'keyboard.interactive.savePassword': 'Сохранить пароль',
'keyboard.interactive.fillTotp': 'Подставить код подтверждения',
// Passphrase Modal for encrypted SSH keys // Passphrase Modal for encrypted SSH keys
'passphrase.title': 'Парольная фраза SSH-ключа', 'passphrase.title': 'Парольная фраза SSH-ключа',
-34
View File
@@ -131,7 +131,6 @@ export const ruVaultMessages: Messages = {
'vault.hosts.moveSelected': 'Переместить в группу ({count})', 'vault.hosts.moveSelected': 'Переместить в группу ({count})',
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.', 'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}', 'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
'vault.hosts.dragSelectionCount': '{count} хостов',
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}', 'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
'vault.hosts.empty.title': 'Настройте свои хосты', 'vault.hosts.empty.title': 'Настройте свои хосты',
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.', 'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
@@ -711,10 +710,6 @@ export const ruVaultMessages: Messages = {
// Quick Switcher // Quick Switcher
'qs.search.placeholder': 'Поиск хостов или вкладок', 'qs.search.placeholder': 'Поиск хостов или вкладок',
'qs.quickConnectTo': 'Быстрое подключение к {target}',
'qs.hosts': 'Хосты',
'qs.tabs': 'Вкладки',
'qs.newWorkspace': 'Новая рабочая область',
'qs.jumpTo': 'Перейти к', 'qs.jumpTo': 'Перейти к',
'qs.localTerminal': 'Локальный терминал', 'qs.localTerminal': 'Локальный терминал',
'qs.localShells': 'Локальные оболочки', 'qs.localShells': 'Локальные оболочки',
@@ -919,35 +914,6 @@ export const ruVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': 'Использовать выбранный SSH-сертификат и сохранённый пароль как запасной вариант.', 'hostDetails.auth.certificate.desc': 'Использовать выбранный SSH-сертификат и сохранённый пароль как запасной вариант.',
'hostDetails.auth.mfaFirst': 'Сначала интерактивная аутентификация', 'hostDetails.auth.mfaFirst': 'Сначала интерактивная аутентификация',
'hostDetails.auth.mfaFirst.desc': 'Для серверов, которым нужны пароль и MFA через keyboard-interactive.', '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.auth.credentialRequired': 'Перед сохранением этого способа входа выберите ключ или сертификат.',
'hostDetails.identity.suggestions': 'Идентификаторы', 'hostDetails.identity.suggestions': 'Идентификаторы',
'hostDetails.identity.missing': 'Идентификатор не найден', 'hostDetails.identity.missing': 'Идентификатор не найден',
@@ -1,23 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { enTerminalMessages } from "./en/terminal";
import { esTerminalMessages } from "./es/terminal";
import { ruTerminalMessages } from "./ru/terminal";
import { zhCNVaultMessages } from "./zh-CN/vault";
import { zhTWVaultMessages } from "./zh-TW/vault";
test("per-GPU stats strings exist in every locale", () => {
const labels = [
[enTerminalMessages, "GPU Device Usage", "GPU {{index}}"],
[esTerminalMessages, "Uso de cada GPU", "GPU {{index}}"],
[ruTerminalMessages, "Использование видеокарт", "GPU {{index}}"],
[zhCNVaultMessages, "GPU 设备使用率", "GPU {{index}}"],
[zhTWVaultMessages, "GPU 裝置使用率", "GPU {{index}}"],
] as const;
for (const [messages, devices, indexLabel] of labels) {
assert.equal(messages["terminal.serverStats.gpuDevices"], devices);
assert.equal(messages["terminal.serverStats.gpuIndex"], indexLabel);
}
});
@@ -47,40 +47,6 @@ 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", () => { test("localized settings include the global default proxy labels", () => {
const keys = [ const keys = [
"settings.terminal.connection.defaultProxy", "settings.terminal.connection.defaultProxy",
-64
View File
@@ -289,14 +289,6 @@ export const zhCNAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode', 'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '检查', 'ai.opencode.check': '检查',
'ai.opencode.resetPath': '重置', '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 Code(小米 MiMo Code CLI,OpenCode 分支)
'ai.mimo.title': 'MiMo Code', 'ai.mimo.title': 'MiMo Code',
@@ -364,14 +356,6 @@ export const zhCNAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': '分钟', 'ai.externalMcp.idleTimeout.minutes': '分钟',
'ai.externalMcp.focusOnHostOpen': 'host_open 时激活窗口', 'ai.externalMcp.focusOnHostOpen': 'host_open 时激活窗口',
'ai.externalMcp.focusOnHostOpen.description': '当 MCP 客户端打开主机连接时,将主窗口切换到前台。关闭后可不受打扰地继续当前工作。', 'ai.externalMcp.focusOnHostOpen.description': '当 MCP 客户端打开主机连接时,将主窗口切换到前台。关闭后可不受打扰地继续当前工作。',
'ai.externalMcp.approvalAlert': '审批提醒',
'ai.externalMcp.approvalAlert.description': '外部 MCP 客户端请求确认时显示系统通知。Netcatty 未聚焦时,任务栏或 Dock 会请求注意。通知使用系统提示音。',
'ai.externalMcp.approvalAlert.off': '关闭',
'ai.externalMcp.approvalAlert.unfocused': '未聚焦时',
'ai.externalMcp.approvalAlert.always': '始终',
'ai.externalMcp.approvalAlert.notificationTitle': '对外 MCP 审批',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待审批',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待审批',
'ai.externalMcp.silentSessions': 'AI 会话静默运行', 'ai.externalMcp.silentSessions': 'AI 会话静默运行',
'ai.externalMcp.silentSessions.description': 'AI 打开的会话不会出现在标签栏,重启后也不会恢复。可随时在托盘面板中查看。', 'ai.externalMcp.silentSessions.description': 'AI 打开的会话不会出现在标签栏,重启后也不会恢复。可随时在托盘面板中查看。',
'ai.externalMcp.sessionIdleTimeout': '已打开会话空闲超时', 'ai.externalMcp.sessionIdleTimeout': '已打开会话空闲超时',
@@ -384,54 +368,6 @@ export const zhCNAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明', 'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
'ai.externalMcp.security': '安全', 'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。', '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': '当前权限模式:{mode}',
'ai.externalMcp.permissionMode.label': '写操作权限模式', 'ai.externalMcp.permissionMode.label': '写操作权限模式',
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。', 'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
+2 -23
View File
@@ -417,7 +417,7 @@ export const zhCNCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主题', 'settings.appearance.themeColor.dark': '深色主题',
'settings.appearance.customCss': '自定义 CSS', 'settings.appearance.customCss': '自定义 CSS',
'settings.appearance.customCss.desc': 'settings.appearance.customCss.desc':
'使用自定义 CSS 个性化界面,修改会立即生效。选中的顶部标签(`.top-tab[data-active="true"]`)会读取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不设置则保持当前外观。', '使用自定义 CSS 个性化界面,修改会立即生效。',
'settings.appearance.customCss.help.ariaLabel': '自定义 CSS 说明', 'settings.appearance.customCss.help.ariaLabel': '自定义 CSS 说明',
'settings.appearance.customCss.help.title': '自定义 CSS 说明', 'settings.appearance.customCss.help.title': '自定义 CSS 说明',
'settings.appearance.customCss.help.body': `主要 UI 区块都暴露了 \`data-section\` 属性,供你用 CSS 定位。编辑后会立即生效。 'settings.appearance.customCss.help.body': `主要 UI 区块都暴露了 \`data-section\` 属性,供你用 CSS 定位。编辑后会立即生效。
@@ -444,22 +444,6 @@ export const zhCNCoreMessages: Messages = {
- \`top-tabs-host-tree-toggle\` - \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\` - \`top-tabs-quick-switcher-toggle\`
选中的顶部标签带有 \`top-tab\` 类和 \`data-active="true"\`。设置下面的变量即可改变选中标签的颜色;不设置则保持当前外观。这些变量不需要 \`!important\`。
- \`--top-tab-active-bg\` — 选中标签的背景(默认是现在的标签背景)
- \`--top-tab-active-fg\` — 选中标签的文字颜色(默认是现在的文字颜色)
- \`--top-tab-active-border\` — 1px 内描边(默认透明,也就是没有边框)
Vaults 标签保持扁平,不使用这块背景。
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### 保险库 ### 保险库
- \`vault-view\` - \`vault-view\`
@@ -553,7 +537,7 @@ Vaults 标签保持扁平,不使用这块背景。
\`\`\` \`\`\`
`, `,
'settings.appearance.customCss.placeholder': 'settings.appearance.customCss.placeholder':
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 选中的顶部标签。不设置这些变量则保持当前外观。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */', '/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 隐藏顶部标签栏里的主机列表开关 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隐藏打开快速切换器的加号按钮 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作侧栏边框(关闭侧栏后不会残留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整个操作侧栏背景,而不只是顶部标签 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改选中的 SFTP 文件行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
'settings.appearance.language': '语言', 'settings.appearance.language': '语言',
'settings.appearance.language.desc': '选择界面语言', 'settings.appearance.language.desc': '选择界面语言',
'settings.appearance.uiFont': '界面字体', 'settings.appearance.uiFont': '界面字体',
@@ -785,7 +769,6 @@ Vaults 标签保持扁平,不使用这块背景。
'vault.hosts.moveSelected': '移动到分组 ({count})', 'vault.hosts.moveSelected': '移动到分组 ({count})',
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。', 'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}', 'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
'vault.hosts.dragSelectionCount': '{count} 个主机',
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}', 'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
'vault.hosts.errors.nameRequired': '主机名称不能为空。', 'vault.hosts.errors.nameRequired': '主机名称不能为空。',
'vault.hosts.empty.title': '设置你的主机', 'vault.hosts.empty.title': '设置你的主机',
@@ -1140,10 +1123,6 @@ Vaults 标签保持扁平,不使用这块背景。
// Quick Switcher // Quick Switcher
'qs.search.placeholder': '搜索主机或标签页', 'qs.search.placeholder': '搜索主机或标签页',
'qs.quickConnectTo': '快速连接到 {target}',
'qs.hosts': '主机',
'qs.tabs': '标签页',
'qs.newWorkspace': '新建工作区',
'qs.jumpTo': '跳转到', 'qs.jumpTo': '跳转到',
'qs.localTerminal': '本地终端', 'qs.localTerminal': '本地终端',
'qs.localShells': '本地 Shell', 'qs.localShells': '本地 Shell',
@@ -26,11 +26,6 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '加载容器列表失败', 'systemManager.errors.loadDocker': '加载容器列表失败',
'systemManager.errors.loadDockerStats': '加载容器性能数据失败', 'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
'systemManager.errors.loadDockerImages': '加载镜像列表失败', '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.loadOverview': '加载系统概览失败',
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败', 'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
'systemManager.errors.loadPorts': '加载监听端口失败', 'systemManager.errors.loadPorts': '加载监听端口失败',
@@ -137,7 +132,7 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)', 'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
'systemManager.tmux.noWindows': '没有窗口', 'systemManager.tmux.noWindows': '没有窗口',
'systemManager.tmux.unavailable': '此主机未检测到 tmux', 'systemManager.tmux.unavailable': '此主机未检测到 tmux',
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)', 'systemManager.docker.unavailable': '此主机未检测到 Docker',
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口', 'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}', 'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
'systemManager.tmux.noPanes': '没有面板', 'systemManager.tmux.noPanes': '没有面板',
@@ -199,36 +194,6 @@ export const zhCnSystemManagerMessages: Messages = {
'systemManager.docker.imagesMeta': '{{count}} 个镜像', 'systemManager.docker.imagesMeta': '{{count}} 个镜像',
'systemManager.docker.start': '启动', 'systemManager.docker.start': '启动',
'systemManager.docker.stop': '停止', '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.unavailable': '此主机未检测到端口工具(ss / netstat)。',
'systemManager.ports.loading': '正在加载监听端口…', 'systemManager.ports.loading': '正在加载监听端口…',
+3 -9
View File
@@ -352,7 +352,7 @@ export const zhCNTerminalMessages: Messages = {
'在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。', '在终端中按 Shift+Enter 时发送配置的文本,而不是普通回车。',
'settings.terminal.behavior.shiftEnterNewlineText': '发送内容', 'settings.terminal.behavior.shiftEnterNewlineText': '发送内容',
'settings.terminal.behavior.shiftEnterNewlineText.desc': 'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。默认 \\e\\r(ESC+回车,传统 Alt+Enter 编码),大多数 TUI CLI 会将其识别为换行。', '使用 \\n 表示换行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜杠。',
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本', 'settings.terminal.behavior.shiftEnterForceText': '在 Win32 输入模式下也发送文本',
'settings.terminal.behavior.shiftEnterForceText.desc': 'settings.terminal.behavior.shiftEnterForceText.desc':
'本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。', '本机 Windows ConPTY 会话协商 Win32 输入模式时,仍然发送上面的文本。Node/Bun 写的 CLI(Claude Code、CodeBuddy)读不到控制台输入记录里的修饰键,需要这个开关才能区分 Shift+Enter。',
@@ -454,10 +454,6 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目录不存在', 'settings.terminal.localShell.startDir.notFound': '目录不存在',
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录', 'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
'settings.terminal.section.connection': '连接', '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': '默认代理',
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。', 'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
'settings.terminal.connection.defaultProxy.none': '无', 'settings.terminal.connection.defaultProxy.none': '无',
@@ -487,8 +483,8 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。', 'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔', 'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。', 'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表刷新间隔', 'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。', 'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔', 'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。', 'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
@@ -1003,13 +999,11 @@ export const zhCNTerminalMessages: Messages = {
'keyboard.interactive.desc': '服务器需要额外的身份验证。', 'keyboard.interactive.desc': '服务器需要额外的身份验证。',
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。', 'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
'keyboard.interactive.response': '响应', 'keyboard.interactive.response': '响应',
'keyboard.interactive.verificationCode': '验证码',
'keyboard.interactive.enterCode': '输入验证码', 'keyboard.interactive.enterCode': '输入验证码',
'keyboard.interactive.enterResponse': '输入响应', 'keyboard.interactive.enterResponse': '输入响应',
'keyboard.interactive.submit': '提交', 'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '验证中...', 'keyboard.interactive.verifying': '验证中...',
'keyboard.interactive.savePassword': '保存密码', 'keyboard.interactive.savePassword': '保存密码',
'keyboard.interactive.fillTotp': '填入验证码',
// Passphrase Modal for encrypted SSH keys // Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 密钥密码', 'passphrase.title': 'SSH 密钥密码',
-31
View File
@@ -290,35 +290,6 @@ export const zhCNVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': '使用选中的 SSH 证书;如果同时保存了密码,可在失败后尝试密码。', 'hostDetails.auth.certificate.desc': '使用选中的 SSH 证书;如果同时保存了密码,可在失败后尝试密码。',
'hostDetails.auth.mfaFirst': '优先交互式认证', 'hostDetails.auth.mfaFirst': '优先交互式认证',
'hostDetails.auth.mfaFirst.desc': '适用于必须通过 keyboard-interactive 输入密码和 MFA 的服务器。', '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.auth.credentialRequired': '保存这种登录方式前,请先选择密钥或证书。',
'hostDetails.identity.suggestions': '身份', 'hostDetails.identity.suggestions': '身份',
'hostDetails.identity.missing': '身份不存在', 'hostDetails.identity.missing': '身份不存在',
@@ -563,8 +534,6 @@ export const zhCNVaultMessages: Messages = {
'terminal.serverStats.cpu': 'CPU 使用率', 'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率', 'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.gpu': 'GPU 使用率', 'terminal.serverStats.gpu': 'GPU 使用率',
'terminal.serverStats.gpuDevices': 'GPU 设备使用率',
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
'terminal.serverStats.memory': '内存使用', 'terminal.serverStats.memory': '内存使用',
'terminal.serverStats.memoryDetails': '内存详情', 'terminal.serverStats.memoryDetails': '内存详情',
'terminal.serverStats.memUsed': '已用', 'terminal.serverStats.memUsed': '已用',
-64
View File
@@ -280,14 +280,6 @@ export const zhTWAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode', 'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
'ai.opencode.check': '檢查', 'ai.opencode.check': '檢查',
'ai.opencode.resetPath': '重設', '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 Code(小米 MiMo Code CLI,OpenCode 分支)
'ai.mimo.title': 'MiMo Code', 'ai.mimo.title': 'MiMo Code',
@@ -354,14 +346,6 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.idleTimeout.minutes': '分鐘', 'ai.externalMcp.idleTimeout.minutes': '分鐘',
'ai.externalMcp.focusOnHostOpen': 'host_open 時啟用視窗', 'ai.externalMcp.focusOnHostOpen': 'host_open 時啟用視窗',
'ai.externalMcp.focusOnHostOpen.description': '當 MCP 用戶端開啟主機連線時,將主視窗切換到前景。關閉後可不受打擾地繼續目前工作。', 'ai.externalMcp.focusOnHostOpen.description': '當 MCP 用戶端開啟主機連線時,將主視窗切換到前景。關閉後可不受打擾地繼續目前工作。',
'ai.externalMcp.approvalAlert': '核准提醒',
'ai.externalMcp.approvalAlert.description': '外部 MCP 用戶端請求確認時顯示系統通知。Netcatty 未聚焦時,工作列或 Dock 會要求注意。通知使用系統提示音。',
'ai.externalMcp.approvalAlert.off': '關閉',
'ai.externalMcp.approvalAlert.unfocused': '未聚焦時',
'ai.externalMcp.approvalAlert.always': '一律',
'ai.externalMcp.approvalAlert.notificationTitle': '對外 MCP 核准',
'ai.externalMcp.approvalAlert.notificationBody': '{tool} 正在等待核准',
'ai.externalMcp.approvalAlert.notificationBodyOnHost': '{host} 上的 {tool} 正在等待核准',
'ai.externalMcp.silentSessions': 'AI 工作階段靜默執行', 'ai.externalMcp.silentSessions': 'AI 工作階段靜默執行',
'ai.externalMcp.silentSessions.description': 'AI 開啟的工作階段不會出現在分頁列,重新啟動後也不會還原。可隨時在系統匣面板中查看。', 'ai.externalMcp.silentSessions.description': 'AI 開啟的工作階段不會出現在分頁列,重新啟動後也不會還原。可隨時在系統匣面板中查看。',
'ai.externalMcp.sessionIdleTimeout': '已開啟工作階段閒置逾時', 'ai.externalMcp.sessionIdleTimeout': '已開啟工作階段閒置逾時',
@@ -374,54 +358,6 @@ export const zhTWAiMessages: Messages = {
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明', 'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
'ai.externalMcp.security': '安全', 'ai.externalMcp.security': '安全',
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。', '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': '目前權限模式:{mode}',
'ai.externalMcp.permissionMode.label': '寫入權限模式', 'ai.externalMcp.permissionMode.label': '寫入權限模式',
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。', 'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。',
+2 -23
View File
@@ -419,7 +419,7 @@ export const zhTWCoreMessages: Messages = {
'settings.appearance.themeColor.dark': '深色主題', 'settings.appearance.themeColor.dark': '深色主題',
'settings.appearance.customCss': '自訂 CSS', 'settings.appearance.customCss': '自訂 CSS',
'settings.appearance.customCss.desc': 'settings.appearance.customCss.desc':
'使用自訂 CSS 個人化介面,修改會立即生效。選取的頂部分頁(`.top-tab[data-active="true"]`)會讀取 `--top-tab-active-bg`、`--top-tab-active-fg` 和 `--top-tab-active-border`。不設定則維持目前外觀。', '使用自訂 CSS 個人化介面,修改會立即生效。',
'settings.appearance.customCss.help.ariaLabel': '自訂 CSS 說明', 'settings.appearance.customCss.help.ariaLabel': '自訂 CSS 說明',
'settings.appearance.customCss.help.title': '自訂 CSS 說明', 'settings.appearance.customCss.help.title': '自訂 CSS 說明',
'settings.appearance.customCss.help.body': `主要 UI 區塊都公開了 \`data-section\` 屬性,供你用 CSS 定位。編輯後會立即生效。 'settings.appearance.customCss.help.body': `主要 UI 區塊都公開了 \`data-section\` 屬性,供你用 CSS 定位。編輯後會立即生效。
@@ -446,22 +446,6 @@ export const zhTWCoreMessages: Messages = {
- \`top-tabs-host-tree-toggle\` - \`top-tabs-host-tree-toggle\`
- \`top-tabs-quick-switcher-toggle\` - \`top-tabs-quick-switcher-toggle\`
選取的頂部分頁帶有 \`top-tab\` 類別和 \`data-active="true"\`。設定下列變數即可改變選取分頁的顏色;不設定則維持目前外觀。這些變數不需要 \`!important\`。
- \`--top-tab-active-bg\` — 選取分頁的背景(預設為目前的分頁背景)
- \`--top-tab-active-fg\` — 選取分頁的文字顏色(預設為目前的文字顏色)
- \`--top-tab-active-border\` — 1px 內描邊(預設透明,也就是沒有邊框)
Vaults 分頁維持扁平,不使用這塊背景。
\`\`\`css
.top-tab[data-active="true"] {
--top-tab-active-bg: #2563eb;
--top-tab-active-fg: #ffffff;
--top-tab-active-border: #1d4ed8;
}
\`\`\`
### 保險庫 ### 保險庫
- \`vault-view\` - \`vault-view\`
@@ -555,7 +539,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
\`\`\` \`\`\`
`, `,
'settings.appearance.customCss.placeholder': 'settings.appearance.customCss.placeholder':
'/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 選取的頂部分頁。不設定這些變數則維持目前外觀。 */\n.top-tab[data-active="true"] {\n --top-tab-active-bg: #2563eb;\n --top-tab-active-fg: #ffffff;\n --top-tab-active-border: #1d4ed8;\n}\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */', '/* 範例 — 由於 Tailwind 優先順序較高,需要使用 !important */\n\n/* 隱藏頂部分頁列裡的主機列表開關 */\n[data-section="top-tabs-host-tree-toggle"] {\n width: 0 !important;\n opacity: 0 !important;\n pointer-events: none !important;\n}\n\n/* 隱藏開啟快速切換器的加號按鈕 */\n[data-section="top-tabs-quick-switcher-toggle"] {\n display: none !important;\n}\n\n/* SFTP / 操作側欄邊框(關閉側欄後不會殘留) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 修改整個操作側欄背景,而不只是頂部分頁 */\n[data-section="terminal-side-panel"],\n[data-section="terminal-side-panel-tabs"],\n[data-section="terminal-side-panel-content"],\n[data-section="terminal-sftp-panel"],\n[data-section="terminal-sftp-pane"],\n[data-section="terminal-sftp-list"],\n[data-section="terminal-sftp-tree"],\n[data-section="terminal-sftp-transfer-queue"] {\n background-color: #1c384a !important;\n}\n\n/* 修改選取的 SFTP 檔案行 */\n[data-section="terminal-sftp-list-row"][data-selected="true"] {\n background-color: #00c851 !important;\n color: #001b10 !important;\n}\n\n/* 加粗分割分割線 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 標示目前聚焦的分割 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 設定 → 終端 → 工作區聚焦指示 → 聚焦窗格顯示邊框 */',
'settings.appearance.language': '語言', 'settings.appearance.language': '語言',
'settings.appearance.language.desc': '選擇介面語言', 'settings.appearance.language.desc': '選擇介面語言',
'settings.appearance.uiFont': '介面字型', 'settings.appearance.uiFont': '介面字型',
@@ -787,7 +771,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
'vault.hosts.moveSelected': '移動到群組 ({count})', 'vault.hosts.moveSelected': '移動到群組 ({count})',
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。', 'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}', 'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
'vault.hosts.dragSelectionCount': '{count} 個主機',
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}', 'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
'vault.hosts.errors.nameRequired': '主機名稱不能為空。', 'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
'vault.hosts.empty.title': '設定你的主機', 'vault.hosts.empty.title': '設定你的主機',
@@ -1142,10 +1125,6 @@ Vaults 分頁維持扁平,不使用這塊背景。
// Quick Switcher // Quick Switcher
'qs.search.placeholder': '搜尋主機或分頁', 'qs.search.placeholder': '搜尋主機或分頁',
'qs.quickConnectTo': '快速連線到 {target}',
'qs.hosts': '主機',
'qs.tabs': '分頁',
'qs.newWorkspace': '新增工作區',
'qs.jumpTo': '跳轉到', 'qs.jumpTo': '跳轉到',
'qs.localTerminal': '本機終端', 'qs.localTerminal': '本機終端',
'qs.localShells': '本機 Shell', 'qs.localShells': '本機 Shell',
@@ -26,11 +26,6 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.errors.loadDocker': '載入容器列表失敗', 'systemManager.errors.loadDocker': '載入容器列表失敗',
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗', 'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
'systemManager.errors.loadDockerImages': '載入映像列表失敗', '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.loadOverview': '載入系統概覽失敗',
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗', 'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
'systemManager.errors.loadPorts': '載入監聽連接埠失敗', 'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
@@ -137,7 +132,7 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)', 'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
'systemManager.tmux.noWindows': '沒有視窗', 'systemManager.tmux.noWindows': '沒有視窗',
'systemManager.tmux.unavailable': '此主機未偵測到 tmux', 'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)', 'systemManager.docker.unavailable': '此主機未偵測到 Docker',
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗', 'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}', 'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
'systemManager.tmux.noPanes': '沒有窗格', 'systemManager.tmux.noPanes': '沒有窗格',
@@ -199,36 +194,6 @@ export const zhTwSystemManagerMessages: Messages = {
'systemManager.docker.imagesMeta': '{{count}} 個映像', 'systemManager.docker.imagesMeta': '{{count}} 個映像',
'systemManager.docker.start': '啟動', 'systemManager.docker.start': '啟動',
'systemManager.docker.stop': '停止', '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.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
'systemManager.ports.loading': '正在載入監聽連接埠…', 'systemManager.ports.loading': '正在載入監聽連接埠…',
+4 -10
View File
@@ -352,7 +352,7 @@ export const zhTWTerminalMessages: Messages = {
'在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。', '在終端中按 Shift+Enter 時傳送設定的文字,而不是一般 Enter。',
'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容', 'settings.terminal.behavior.shiftEnterNewlineText': '傳送內容',
'settings.terminal.behavior.shiftEnterNewlineText.desc': 'settings.terminal.behavior.shiftEnterNewlineText.desc':
'使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。預設 \\e\\r(ESC+Enter,傳統 Alt+Enter 編碼),大多數 TUI CLI 會將其識別為換行。', '使用 \\n 表示換行,\\t 表示 Tab,\\e 表示 ESC,\\\\ 表示反斜線。',
'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字', 'settings.terminal.behavior.shiftEnterForceText': '在 Win32 輸入模式下也傳送文字',
'settings.terminal.behavior.shiftEnterForceText.desc': 'settings.terminal.behavior.shiftEnterForceText.desc':
'本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。', '本機 Windows ConPTY 工作階段協商 Win32 輸入模式時,仍然傳送上面的文字。Node/Bun 寫的 CLI(Claude Code、CodeBuddy)讀不到主控台輸入記錄中的修飾鍵,需要這個開關才能區分 Shift+Enter。',
@@ -454,10 +454,6 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.localShell.startDir.notFound': '目錄不存在', 'settings.terminal.localShell.startDir.notFound': '目錄不存在',
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄', 'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
'settings.terminal.section.connection': '連線', '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': '預設代理',
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。', 'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
'settings.terminal.connection.defaultProxy.none': '無', 'settings.terminal.connection.defaultProxy.none': '無',
@@ -487,8 +483,8 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。', 'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔', 'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。', 'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔', 'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。', 'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔', 'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。', 'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
@@ -1003,13 +999,11 @@ export const zhTWTerminalMessages: Messages = {
'keyboard.interactive.desc': '伺服器需要額外的身分驗證。', 'keyboard.interactive.desc': '伺服器需要額外的身分驗證。',
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。', 'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。',
'keyboard.interactive.response': '回應', 'keyboard.interactive.response': '回應',
'keyboard.interactive.verificationCode': '驗證碼',
'keyboard.interactive.enterCode': '輸入驗證碼', 'keyboard.interactive.enterCode': '輸入驗證碼',
'keyboard.interactive.enterResponse': '輸入回應', 'keyboard.interactive.enterResponse': '輸入回應',
'keyboard.interactive.submit': '送出', 'keyboard.interactive.submit': '提交',
'keyboard.interactive.verifying': '驗證中...', 'keyboard.interactive.verifying': '驗證中...',
'keyboard.interactive.savePassword': '儲存密碼', 'keyboard.interactive.savePassword': '儲存密碼',
'keyboard.interactive.fillTotp': '填入驗證碼',
// Passphrase Modal for encrypted SSH keys // Passphrase Modal for encrypted SSH keys
'passphrase.title': 'SSH 金鑰密碼', 'passphrase.title': 'SSH 金鑰密碼',
-31
View File
@@ -290,35 +290,6 @@ export const zhTWVaultMessages: Messages = {
'hostDetails.auth.certificate.desc': '使用選取的 SSH 憑證;如果同時儲存了密碼,可在失敗後嘗試密碼。', 'hostDetails.auth.certificate.desc': '使用選取的 SSH 憑證;如果同時儲存了密碼,可在失敗後嘗試密碼。',
'hostDetails.auth.mfaFirst': '優先互動式驗證', 'hostDetails.auth.mfaFirst': '優先互動式驗證',
'hostDetails.auth.mfaFirst.desc': '適用於必須透過 keyboard-interactive 輸入密碼和 MFA 的伺服器。', '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.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
'hostDetails.identity.suggestions': '身分', 'hostDetails.identity.suggestions': '身分',
'hostDetails.identity.missing': '身分不存在', 'hostDetails.identity.missing': '身分不存在',
@@ -563,8 +534,6 @@ export const zhTWVaultMessages: Messages = {
'terminal.serverStats.cpu': 'CPU 使用率', 'terminal.serverStats.cpu': 'CPU 使用率',
'terminal.serverStats.cpuCores': 'CPU 核心使用率', 'terminal.serverStats.cpuCores': 'CPU 核心使用率',
'terminal.serverStats.gpu': 'GPU 使用率', 'terminal.serverStats.gpu': 'GPU 使用率',
'terminal.serverStats.gpuDevices': 'GPU 裝置使用率',
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
'terminal.serverStats.memory': '記憶體使用', 'terminal.serverStats.memory': '記憶體使用',
'terminal.serverStats.memoryDetails': '記憶體詳情', 'terminal.serverStats.memoryDetails': '記憶體詳情',
'terminal.serverStats.memUsed': '已用', 'terminal.serverStats.memUsed': '已用',
-35
View File
@@ -1,35 +0,0 @@
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();
});
-28
View File
@@ -1,28 +0,0 @@
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,21 +1,10 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import test from 'node:test'; 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 type { AISession } from '../../infrastructure/ai/types';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { import {
cleanupClosedTerminalSessions, cleanupClosedTerminalSessions,
cleanupDeletedAIChatSessions, cleanupDeletedAIChatSessions,
cleanupOrphanedAISessions,
cleanupSdkAgentSessions, cleanupSdkAgentSessions,
latestAIActiveSessionMapSnapshot,
latestAISessionsSnapshot,
setLatestAIActiveSessionMapSnapshot,
setLatestAISessionsSnapshot,
} from './aiStateSnapshots'; } from './aiStateSnapshots';
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => { test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
@@ -72,92 +61,6 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
} as never; } 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 () => { test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots'); const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
@@ -1,90 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { presentExternalMcpApprovalNotification } from './externalMcpApprovalAlert.ts';
const t = (key: string, values?: Record<string, string | number | boolean | null | undefined>) => {
if (key === 'ai.externalMcp.approvalAlert.notificationTitle') return 'External MCP approval';
if (key === 'ai.externalMcp.approvalAlert.notificationBody') return `${values?.tool} is waiting for approval`;
if (key === 'ai.externalMcp.approvalAlert.notificationBodyOnHost') {
return `${values?.tool} on ${values?.host} is waiting for approval`;
}
return key;
};
test('approval alerts stay quiet when the preference is off', () => {
const calls: unknown[] = [];
const shown = presentExternalMcpApprovalNotification({
toolName: 'sftp.write',
dedupeKey: 'mcp_approval_1',
mode: 'off',
t,
notify: (payload) => calls.push(payload),
});
assert.equal(shown, false);
assert.deepEqual(calls, []);
});
test('unfocused approval alerts name the tool and host and defer focus to the main process', () => {
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
const shown = presentExternalMcpApprovalNotification({
toolName: 'sftp.write',
hostLabel: 'bastion',
dedupeKey: 'mcp_approval_2',
mode: 'unfocused',
t,
notify: (payload) => calls.push(payload),
});
assert.equal(shown, true);
assert.deepEqual(calls, [{
title: 'External MCP approval',
body: 'sftp.write on bastion is waiting for approval',
dedupeKey: 'mcp_approval_2',
whenUnfocused: true,
}]);
});
test('approval alerts show the MCP tool name for an internal rpc id', () => {
const calls: Array<{ body: string }> = [];
const shown = presentExternalMcpApprovalNotification({
toolName: 'netcatty/exec',
hostLabel: '127.0.0.1',
dedupeKey: 'mcp_approval_exec',
mode: 'always',
t,
notify: (payload) => calls.push(payload),
});
assert.equal(shown, true);
assert.equal(calls.length, 1);
assert.match(calls[0].body, /terminal_execute/);
assert.equal(calls[0].body, 'terminal_execute on 127.0.0.1 is waiting for approval');
});
test('approval alerts keep an id that has no MCP tool name', () => {
const calls: Array<{ body: string }> = [];
presentExternalMcpApprovalNotification({
toolName: 'netcatty/not-a-tool',
dedupeKey: 'mcp_approval_unknown',
mode: 'unfocused',
t,
notify: (payload) => calls.push(payload),
});
assert.equal(calls[0].body, 'netcatty/not-a-tool is waiting for approval');
});
test('always approval alerts notify even while a window may be focused', () => {
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
presentExternalMcpApprovalNotification({
toolName: 'terminal.execute',
dedupeKey: 'mcp_approval_3',
mode: 'always',
t,
notify: (payload) => calls.push(payload),
});
assert.deepEqual(calls, [{
title: 'External MCP approval',
body: 'terminal.execute is waiting for approval',
dedupeKey: 'mcp_approval_3',
whenUnfocused: false,
}]);
});
@@ -1,91 +0,0 @@
import type { ApprovalRequest } from '../../infrastructure/ai/shared/approvalGate';
import cattyToolSpecs from '../../infrastructure/ai/harness/generated/cattyToolSpecs.json';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import {
readExternalMcpApprovalAlertMode,
type ExternalMcpApprovalAlertMode,
} from './useExternalMcpToggleState';
type CatalogToolSpec = {
toolName?: string;
rpcMethod?: string | null;
};
const mcpToolNameByRpcMethod = new Map<string, string>();
for (const spec of cattyToolSpecs as CatalogToolSpec[]) {
if (!spec.rpcMethod || !spec.toolName || mcpToolNameByRpcMethod.has(spec.rpcMethod)) continue;
mcpToolNameByRpcMethod.set(spec.rpcMethod, spec.toolName);
}
/**
* Name shown on the External MCP approval card.
* Catalog MCP tool names win over internal RPC ids such as `netcatty/exec`.
* An id with no catalog tool name is shown as-is.
*/
export function externalMcpApprovalToolLabel(toolName: string | undefined): string {
const raw = toolName?.trim() || '';
if (!raw) return '';
return mcpToolNameByRpcMethod.get(raw) || raw;
}
export type ExternalMcpApprovalNotification = {
title: string;
body: string;
dedupeKey: string;
whenUnfocused: boolean;
};
type Translate = (
key: string,
values?: Record<string, string | number | boolean | null | undefined>,
) => string;
/**
* Build the system notification for one External MCP approval.
* Returns null when the preference is off. Focus gating stays in the main
* process so every Netcatty window is considered, not only the sender.
*/
export function presentExternalMcpApprovalNotification(options: {
toolName: string;
hostLabel?: string;
dedupeKey: string;
mode: ExternalMcpApprovalAlertMode;
t: Translate;
notify: (payload: ExternalMcpApprovalNotification) => void;
}): boolean {
if (options.mode === 'off') return false;
const tool = externalMcpApprovalToolLabel(options.toolName);
const host = options.hostLabel?.trim() || '';
const body = host
? options.t('ai.externalMcp.approvalAlert.notificationBodyOnHost', { tool, host })
: options.t('ai.externalMcp.approvalAlert.notificationBody', { tool });
options.notify({
title: options.t('ai.externalMcp.approvalAlert.notificationTitle'),
body,
dedupeKey: options.dedupeKey,
whenUnfocused: options.mode === 'unfocused',
});
return true;
}
export function publishExternalMcpApprovalAlertMode(): void {
void netcattyBridge.get()?.setExternalMcpApprovalAlertMode?.({
mode: readExternalMcpApprovalAlertMode(),
});
}
export function notifyExternalMcpApproval(
request: Pick<ApprovalRequest, 'toolName' | 'toolCallId' | 'target'>,
t: Translate,
): boolean {
return presentExternalMcpApprovalNotification({
toolName: request.toolName,
hostLabel: request.target?.label || request.target?.hostname || '',
dedupeKey: request.toolCallId,
mode: readExternalMcpApprovalAlertMode(),
t,
notify: (payload) => {
void netcattyBridge.get()?.showSystemNotification?.(payload);
},
});
}
+5 -6
View File
@@ -13,11 +13,10 @@ type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "last
export interface CaptureInheritedCwdOptions { export interface CaptureInheritedCwdOptions {
/** /**
* The session's live tracked cwd (OSC 7 or the post-command cwd probe), * The session's live tracked cwd (OSC 7), sourced from the terminal-state
* sourced from the terminal-state cwd map rather than the session object. * cwd map rather than the session object. This is the freshest value and the
* This is the freshest value and the one that reflects `cd`s in a running * only one that reflects `cd`s in a running LOCAL terminal (whose live cwd is
* local terminal. The live value stays off `TerminalSession.lastCwd` until * never mirrored onto `TerminalSession.lastCwd`).
* session restore persists it.
*/ */
liveCwd?: string; liveCwd?: string;
/** /**
@@ -36,7 +35,7 @@ export const DEFAULT_INHERITED_CWD_PROBE_TIMEOUT_MS = 1500;
/** /**
* Resolve the working directory a clone/split should inherit from its source. * Resolve the working directory a clone/split should inherit from its source.
* *
* Priority: live tracked cwd (OSC 7 or post-command probe) -> live SSH `/proc` probe (when allowed) * Priority: live tracked cwd (OSC 7) -> live SSH `/proc` probe (when allowed)
* -> tracked `lastCwd` snapshot -> local `localStartDir`. The probe is raced * -> tracked `lastCwd` snapshot -> local `localStartDir`. The probe is raced
* against a short timeout so a slow/wedged connection can't block tab creation, * against a short timeout so a slow/wedged connection can't block tab creation,
* and is skipped entirely when `allowSshProbe` is false. Returns undefined when * and is skipped entirely when `allowSshProbe` is false. Returns undefined when
@@ -10,14 +10,12 @@ import {
resolveThemeAccentForeground, resolveThemeAccentForeground,
migrateIncomingTerminalFontId, migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault, migrateMaterializedAutoCloseOnExitDefault,
migrateMaterializedShiftEnterNewlineDefault,
} from "./settingsStateDefaults.ts"; } from "./settingsStateDefaults.ts";
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts"; import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
import { import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY, STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS, STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
} from "../../infrastructure/config/storageKeys.ts"; } from "../../infrastructure/config/storageKeys.ts";
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts"; import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
@@ -216,76 +214,3 @@ test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explic
delete (globalThis as { localStorage?: unknown }).localStorage; delete (globalThis as { localStorage?: unknown }).localStorage;
} }
}); });
test("migrateMaterializedShiftEnterNewlineDefault rewrites the old default once", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
assert.equal(migrated.shiftEnterNewlineText, "\\e\\r");
assert.equal(migrated.scrollback, 3000);
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
"\\n",
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault rewrites a raw stored newline", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ shiftEnterNewlineText: "\n" }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
"\\e\\r",
);
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault keeps custom Shift+Enter text", () => {
const store = installMemoryLocalStorage();
try {
store.set(
STORAGE_KEY_TERM_SETTINGS,
JSON.stringify({ shiftEnterNewlineText: "\\r", scrollback: 1000 }),
);
migrateMaterializedShiftEnterNewlineDefault();
assert.deepEqual(JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!), {
shiftEnterNewlineText: "\\r",
scrollback: 1000,
});
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
test("migrateMaterializedShiftEnterNewlineDefault leaves a fresh install without a settings blob", () => {
const store = installMemoryLocalStorage();
try {
migrateMaterializedShiftEnterNewlineDefault();
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
} finally {
delete (globalThis as { localStorage?: unknown }).localStorage;
}
});
+1 -28
View File
@@ -1,15 +1,8 @@
import { import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
isLegacyDefaultShiftEnterNewlineText,
type HotkeyScheme,
type SessionLogFormat,
type TerminalSettings,
} from '../../domain/models';
import { import {
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
STORAGE_KEY_TERM_FONT_FAMILY, STORAGE_KEY_TERM_FONT_FAMILY,
STORAGE_KEY_TERM_SETTINGS, STORAGE_KEY_TERM_SETTINGS,
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
} from '../../infrastructure/config/storageKeys'; } from '../../infrastructure/config/storageKeys';
import { import {
isDeprecatedPrimaryFontId, isDeprecatedPrimaryFontId,
@@ -98,26 +91,6 @@ export function migrateMaterializedAutoCloseOnExitDefault(): void {
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true); localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
} }
/**
* One-time migration for the Shift+Enter default flip (#3770). Builds before
* that change materialized `\\n` into saved settings, so upgraders kept a
* bare newline and TUI CLIs still submitted. Rewrite only that old default
* (escaped or raw) to ESC+CR, then persist the marker. A later explicit `\\n`
* is left alone. Custom text is never rewritten. Sync and import do not
* read this marker; an old `\\n` from another device stays as stored.
*/
export function migrateMaterializedShiftEnterNewlineDefault(): void {
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED)) return;
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
if (stored && isLegacyDefaultShiftEnterNewlineText(stored.shiftEnterNewlineText)) {
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, {
...stored,
shiftEnterNewlineText: DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
});
}
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED, true);
}
// Auto-detect default hotkey scheme based on platform // Auto-detect default hotkey scheme based on platform
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme = export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform) typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
@@ -30,7 +30,6 @@ import {
isMissingDirectoryReplacePathError, isMissingDirectoryReplacePathError,
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths, promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
} from "./directoryReplacePromotion"; } from "./directoryReplacePromotion";
import { unwrapSftpIpcError } from "./errors";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore"; import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export interface DedicatedResumeDeps { export interface DedicatedResumeDeps {
@@ -180,11 +179,9 @@ export async function openTransferSftpSession(
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens // Restart/resume owns an independent SSH dial. Normal pooled transfer opens
// leave reuseTransport enabled so the main-process registry can reuse an // leave reuseTransport enabled so the main-process registry can reuse an
// authenticated endpoint even when no terminal tab exists. // 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 const openOpts = wantDedicated
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const } ? { ...credentials, reuseTransport: false as const }
: { ...credentials, hostKeyUnattended: true as const }; : credentials;
// The main-process auth driver owns method ordering, fallback, MFA and // The main-process auth driver owns method ordering, fallback, MFA and
// retry. A renderer-side key-then-password retry performs a second full SSH // retry. A renderer-side key-then-password retry performs a second full SSH
@@ -743,7 +740,7 @@ async function resumeSingleFileWithDedicatedSession(
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean }; const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
return { return {
success: false, success: false,
error: unwrapSftpIpcError(error), error: err instanceof Error ? err.message : String(error),
needsAttention: !!err.dedicatedAttention, needsAttention: !!err.dedicatedAttention,
resetCheckpoint: !!err.resetCheckpoint, resetCheckpoint: !!err.resetCheckpoint,
}; };
@@ -38,20 +38,6 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
assert.equal(attempts, 1); 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 () => { test("runWithTransferRetry does not retry cancel", async () => {
let attempts = 0; let attempts = 0;
await assert.rejects( await assert.rejects(
-3
View File
@@ -6,9 +6,6 @@ import { isSessionError } from "./errors";
* path) should not be retried. * path) should not be retried.
*/ */
export function isTransientTransferError(err: unknown): boolean { 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 (isSessionError(err)) return true;
if (!(err instanceof Error)) return false; if (!(err instanceof Error)) return false;
const msg = err.message.toLowerCase(); const msg = err.message.toLowerCase();
+3 -4
View File
@@ -4,7 +4,6 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models"; import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts"; import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types"; import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
import { unwrapSftpIpcError } from "./errors";
import { useSftpDirectoryListing } from "./useSftpDirectoryListing"; import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
import { useSftpHostCredentials } from "./useSftpHostCredentials"; import { useSftpHostCredentials } from "./useSftpHostCredentials";
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache"; import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
@@ -958,7 +957,7 @@ export const useSftpConnections = ({
if (!isTargetConnectionAtPath(startPath)) return; if (!isTargetConnectionAtPath(startPath)) return;
updateTargetTab((prev) => ({ updateTargetTab((prev) => ({
...prev, ...prev,
error: unwrapSftpIpcError(err) || "Failed to list directory", error: err instanceof Error ? err.message : "Failed to list directory",
loading: false, loading: false,
reconnecting: false, reconnecting: false,
})); }));
@@ -1264,14 +1263,14 @@ export const useSftpConnections = ({
? { ? {
...prev.connection, ...prev.connection,
status: "error", status: "error",
error: unwrapSftpIpcError(err) || "Connection failed", error: err instanceof Error ? err.message : "Connection failed",
} }
: null, : null,
files: isReconnectAttempt ? [] : prev.files, files: isReconnectAttempt ? [] : prev.files,
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles, selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
error: isReconnectAttempt error: isReconnectAttempt
? "sftp.error.reconnectFailed" ? "sftp.error.reconnectFailed"
: (unwrapSftpIpcError(err) || "Connection failed"), : (err instanceof Error ? err.message : "Connection failed"),
loading: false, loading: false,
reconnecting: false, reconnecting: false,
})); }));
@@ -3,7 +3,6 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials"; import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth"; import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { resolveHostKeepalive } from "../../../domain/host"; import { resolveHostKeepalive } from "../../../domain/host";
import { connectionTotpFields } from "../../../domain/totpConfig";
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts"; import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
import { import {
findIncompleteProxyIdentityId, findIncompleteProxyIdentityId,
@@ -122,7 +121,6 @@ export const buildSftpHostCredentials = ({
authMethod: jumpAuth.authMethod, authMethod: jumpAuth.authMethod,
requiresMfa: !!jumpHost.requiresMfa, requiresMfa: !!jumpHost.requiresMfa,
password: jumpPassword, password: jumpPassword,
...connectionTotpFields(jumpHost),
privateKey: jumpKeyAuth.privateKey, privateKey: jumpKeyAuth.privateKey,
certificate: jumpKey?.certificate, certificate: jumpKey?.certificate,
passphrase: jumpKeyAuth.passphrase, passphrase: jumpKeyAuth.passphrase,
@@ -184,7 +182,6 @@ export const buildSftpHostCredentials = ({
requiresMfa: !!host.requiresMfa, requiresMfa: !!host.requiresMfa,
port: host.port || 22, port: host.port || 22,
password, password,
...connectionTotpFields(host),
privateKey: keyAuth.privateKey, privateKey: keyAuth.privateKey,
certificate: key?.certificate, certificate: key?.certificate,
passphrase: keyAuth.passphrase, passphrase: keyAuth.passphrase,
@@ -56,166 +56,6 @@ 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 = { type StartOptions = {
transferId: string; transferId: string;
sourcePath: string; sourcePath: string;
@@ -872,127 +712,3 @@ test("owned cancelled directory settles recoverable children without dropping co
await rm(root, { recursive: true, force: true }); 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();
}
});
+28 -49
View File
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
import { logger } from "../../../lib/logger"; import { logger } from "../../../lib/logger";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore"; import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
import { SftpPane } from "./types"; import { SftpPane } from "./types";
import { isMissingStatError, unwrapSftpIpcError } from "./errors"; import { isMissingStatError } from "./errors";
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps"; import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
import { useSftpTransferConflictOps } from "./transferConflictOps"; import { useSftpTransferConflictOps } from "./transferConflictOps";
import { useSftpTransferTaskOps } from "./transferTaskOps"; import { useSftpTransferTaskOps } from "./transferTaskOps";
@@ -191,21 +191,6 @@ export const useSftpTransfers = ({
const { t } = useI18n(); const { t } = useI18n();
const tRef = useRef(t); const tRef = useRef(t);
tRef.current = 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. // Ref so progress flushes always see the latest visibility without rebinding.
const surfaceVisibleRef = useRef(surfaceVisible); const surfaceVisibleRef = useRef(surfaceVisible);
surfaceVisibleRef.current = surfaceVisible; surfaceVisibleRef.current = surfaceVisible;
@@ -483,7 +468,7 @@ export const useSftpTransfers = ({
setTransfers, setTransfers,
listLocalFiles, listLocalFiles,
listRemoteFiles, listRemoteFiles,
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined, acquireTransferSession,
}); });
const processTransfer = async ( const processTransfer = async (
@@ -617,9 +602,8 @@ export const useSftpTransfers = ({
// survive browse park and tab hide (pool holders, not panel map ids). // survive browse park and tab hide (pool holders, not panel map ids).
if (!sourcePane.connection?.isLocal) { if (!sourcePane.connection?.isLocal) {
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId; const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
const acquireSourceSession = acquireTransferSessionRef.current; if (acquireTransferSession && sourceHostId) {
if (acquireSourceSession && sourceHostId) { sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
sourceSftpId = sourceWorkLease.sftpId; sourceSftpId = sourceWorkLease.sftpId;
} }
if (!sourceSftpId) { if (!sourceSftpId) {
@@ -631,9 +615,8 @@ export const useSftpTransfers = ({
if (!targetPane.connection?.isLocal) { if (!targetPane.connection?.isLocal) {
const targetHostId = task.targetHostId || targetPane.connection?.hostId; const targetHostId = task.targetHostId || targetPane.connection?.hostId;
const acquireTargetSession = acquireTransferSessionRef.current; if (acquireTransferSession && targetHostId) {
if (acquireTargetSession && targetHostId) { targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
targetSftpId = targetWorkLease.sftpId; targetSftpId = targetWorkLease.sftpId;
} }
if (!targetSftpId) { if (!targetSftpId) {
@@ -828,8 +811,8 @@ export const useSftpTransfers = ({
})(); })();
// For single files: start size discovery without blocking the conflict // For single files: start size discovery without blocking the conflict
// check, but always settle the promise before any dialog or transfer // check, but keep the promise so an actual conflict can await fresh
// launch (bounded per-task; the task is already visible/cancellable). // size metadata before the dialog is shown.
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize(); const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
// Only await conflict check (fast single stat call) // Only await conflict check (fast single stat call)
@@ -840,23 +823,19 @@ export const useSftpTransfers = ({
// null means the plan size is still unknown. A verified 0-byte stat is 0, // 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. // not null, so an empty file does not pick up a stale listing size.
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null; let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
if (sizeDiscoveryPromise) { if (conflict && sizeDiscoveryPromise) {
// The task's totalBytes may still be 0 here because discovery ran // The task's totalBytes may still be 0 here because discovery ran
// deferred and updateTask never mutates the local task copy. Await // deferred and updateTask never mutates the local task copy. Await
// discovery even without a conflict so its statLocal updateTask cannot // discovery (bounded per-task; the task is already visible/cancellable)
// resolve after the transfer finished and overwrite the bridge's // so the overwrite dialog shows the real source size, not 0 (#3559).
// authoritative total with a stale late size (the conflict dialog also
// needs it: #3559).
const discovered = await sizeDiscoveryPromise; const discovered = await sizeDiscoveryPromise;
// Cancel during this stat must win before an apply-to-all Replace // Cancel during this stat must win before an apply-to-all Replace
// deletes an existing symlink. // deletes an existing symlink.
if (cancelledTasksRef.current.has(task.id)) return "cancelled"; if (cancelledTasksRef.current.has(task.id)) return "cancelled";
if (discovered && discovered.size >= 0) { if (discovered && discovered.size >= 0) {
if (conflict) { conflict.newSize = discovered.size;
conflict.newSize = discovered.size; if (discovered.lastModified) conflict.newModified = discovered.lastModified;
if (discovered.lastModified) conflict.newModified = discovered.lastModified; if (discovered.plan) verifiedPlanBytes = discovered.size;
if (discovered.plan) verifiedPlanBytes = discovered.size;
}
} }
} }
@@ -1139,7 +1118,7 @@ export const useSftpTransfers = ({
updateTask({ updateTask({
status: "failed", status: "failed",
error: unwrapSftpIpcError(err) || "Transfer failed", error: err instanceof Error ? err.message : "Transfer failed",
endTime: Date.now(), endTime: Date.now(),
speed: 0, speed: 0,
}); });
@@ -1288,24 +1267,25 @@ export const useSftpTransfers = ({
// Prefer the fresh re-stat for remote files: a cached listing size can // Prefer the fresh re-stat for remote files: a cached listing size can
// be stale and undersized, which would silently truncate downloads // be stale and undersized, which would silently truncate downloads
// (#3559). Remote files without a live stat use plan 0 so the bridge // (#3559). Remote files without a live stat use plan 0 so the bridge
// measures the live size. Local files also start unknown so deferred // measures the live size; local sources keep the pane metadata.
// discovery reads their current size after edits, not the cached listing.
const fileEntry = fileEntryMap?.get(file.name); const fileEntry = fileEntryMap?.get(file.name);
const freshMetadata = freshRemoteMetadata.get(file.name); const freshMetadata = freshRemoteMetadata.get(file.name);
const fileSize = file.isDirectory const fileSize = file.isDirectory
? 0 ? 0
: freshMetadata : freshMetadata
? freshMetadata.size ? freshMetadata.size
: 0; : sourcePane.connection!.isLocal
// Only keep an mtime paired with a live re-stat or directory totals. ? (fileEntry?.size ?? 0)
// A file without a live stat must stay fully unknown : 0;
// (size 0 AND mtime 0 in the plan): // 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):
// discoverTransferSize and the conflict check both treat a nonzero // discoverTransferSize and the conflict check both treat a nonzero
// sourceLastModified as proof the size is already known, so a stale // sourceLastModified as proof the size is already known, so a stale
// mtime would suppress the deferred source re-stat. The overwrite // mtime would suppress the deferred source re-stat. The overwrite
// dialog still gets the pane listing's cached entry via // dialog still gets the pane listing's cached entry via
// discoverTransferSize's cachedListingStatForDialog fallback. // discoverTransferSize's cachedListingStatForDialog fallback.
const sourceLastModified = !file.isDirectory && !freshMetadata const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
? 0 ? 0
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0); : (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
@@ -1524,8 +1504,8 @@ export const useSftpTransfers = ({
try { try {
const result = await retryExternalDragDropFileUpload(task, { const result = await retryExternalDragDropFileUpload(task, {
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId), getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
acquireTransferSession: acquireTransferSessionRef.current acquireTransferSession: acquireTransferSession
? (hostId, id) => acquireTransferSessionLive(hostId, id) ? (hostId, id) => acquireTransferSession(hostId, id)
: undefined, : undefined,
startStreamTransfer: async (options) => { startStreamTransfer: async (options) => {
const bridge = netcattyBridge.get(); const bridge = netcattyBridge.get();
@@ -2017,9 +1997,8 @@ export const useSftpTransfers = ({
let sourceWorkLease: TransferConnectionLease | null = null; let sourceWorkLease: TransferConnectionLease | null = null;
let workingSourceSftpId = params.sftpId; let workingSourceSftpId = params.sftpId;
try { try {
const acquireDownloadSession = acquireTransferSessionRef.current; if (acquireTransferSession && params.sourceHostId) {
if (acquireDownloadSession && params.sourceHostId) { sourceWorkLease = await acquireTransferSession(
sourceWorkLease = await acquireDownloadSession(
params.sourceHostId, params.sourceHostId,
`${task.id}:work-source`, `${task.id}:work-source`,
); );
@@ -2085,7 +2064,7 @@ export const useSftpTransfers = ({
const isCancelled = cancelledTasksRef.current.has(task.id); const isCancelled = cancelledTasksRef.current.has(task.id);
// Clean up cancelled task tracking to prevent memory leak // Clean up cancelled task tracking to prevent memory leak
if (isCancelled) cancelledTasksRef.current.delete(task.id); if (isCancelled) cancelledTasksRef.current.delete(task.id);
const errMsg = unwrapSftpIpcError(err); const errMsg = err instanceof Error ? err.message : String(err);
patchDownload({ patchDownload({
status: isCancelled ? "cancelled" : "failed", status: isCancelled ? "cancelled" : "failed",
error: isCancelled ? undefined : errMsg, error: isCancelled ? undefined : errMsg,
@@ -1,39 +0,0 @@
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);
}
@@ -1,225 +0,0 @@
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,
};
}
@@ -56,28 +56,6 @@ test('active reconnect state blocks duplicate requests and notifies subscribers'
unsubscribe(); unsubscribe();
}); });
test('enter reconnect stays off until the session allows it and does not queue', () => {
const registry = createTerminalReconnectRegistry();
const requests: string[] = [];
assert.equal(registry.requestEnterReconnect('session-1'), false);
registry.register('session-1', () => requests.push('session-1'));
assert.deepEqual(requests, []);
registry.setEnterReconnectEnabled('session-1', true);
assert.equal(registry.requestEnterReconnect('session-1'), true);
assert.deepEqual(requests, ['session-1']);
registry.setActive('session-1', true);
assert.equal(registry.requestEnterReconnect('session-1'), false);
assert.deepEqual(requests, ['session-1']);
registry.setActive('session-1', false);
registry.setEnterReconnectEnabled('session-1', false);
assert.equal(registry.requestEnterReconnect('session-1'), false);
assert.deepEqual(requests, ['session-1']);
});
test('unregistering the current handler clears active reconnect state', () => { test('unregistering the current handler clears active reconnect state', () => {
const registry = createTerminalReconnectRegistry(); const registry = createTerminalReconnectRegistry();
const unregister = registry.register('session-1', () => {}); const unregister = registry.register('session-1', () => {});
+1 -22
View File
@@ -4,7 +4,6 @@ export const createTerminalReconnectRegistry = () => {
const handlers = new Map<string, TerminalReconnectHandler>(); const handlers = new Map<string, TerminalReconnectHandler>();
const pendingRequests = new Set<string>(); const pendingRequests = new Set<string>();
const activeSessionIds = new Set<string>(); const activeSessionIds = new Set<string>();
const enterReconnectEnabledIds = new Set<string>();
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
const emit = () => { const emit = () => {
@@ -52,27 +51,7 @@ export const createTerminalReconnectRegistry = () => {
return true; return true;
}; };
// Enter-reconnect while a compose bar is open. Explicit reconnect clicks use return { register, request, setActive, isActive, subscribe };
// request(); this path stays off during auth, host-key, and blocking overlays.
const setEnterReconnectEnabled = (sessionId: string, enabled: boolean): void => {
if (enabled) enterReconnectEnabledIds.add(sessionId);
else enterReconnectEnabledIds.delete(sessionId);
};
const requestEnterReconnect = (sessionId: string): boolean => {
if (!enterReconnectEnabledIds.has(sessionId)) return false;
return request(sessionId);
};
return {
register,
request,
requestEnterReconnect,
setEnterReconnectEnabled,
setActive,
isActive,
subscribe,
};
}; };
export const terminalReconnectRegistry = createTerminalReconnectRegistry(); export const terminalReconnectRegistry = createTerminalReconnectRegistry();
@@ -1,36 +1,19 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs'; import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
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 { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import {
readExternalMcpHttpAllowCustomBind,
readExternalMcpHttpEnabled,
readExternalMcpHttpListenAddress,
readExternalMcpHttpPort,
readExternalMcpHttpSettings,
writeExternalMcpHttpAllowCustomBind,
writeExternalMcpHttpEnabled,
writeExternalMcpHttpListenAddress,
writeExternalMcpHttpPort,
} from "./useExternalMcpToggleState.ts";
import { import {
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS, EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
applyAIPrivacySettings,
readAIPrivacySettings,
applyExternalMcpRedactionSettings,
readExternalMcpRedactionSettings,
bumpExternalMcpEnableGenerationForTests, bumpExternalMcpEnableGenerationForTests,
createExternalMcpStartupSyncPlan, createExternalMcpStartupSyncPlan,
getExternalMcpEnableGenerationForTests, getExternalMcpEnableGenerationForTests,
getExternalMcpStartupReadyWaiterCountForTests, getExternalMcpStartupReadyWaiterCountForTests,
isExternalMcpStartupReady, isExternalMcpStartupReady,
markExternalMcpStartupReady, markExternalMcpStartupReady,
normalizeExternalMcpApprovalAlertMode,
normalizeExternalMcpIdleTimeoutMinutes, normalizeExternalMcpIdleTimeoutMinutes,
normalizeExternalMcpMode, normalizeExternalMcpMode,
normalizeSessionIdleTimeoutMinutes, normalizeSessionIdleTimeoutMinutes,
readExternalMcpApprovalAlertMode,
readExternalMcpFocusOnHostOpen, readExternalMcpFocusOnHostOpen,
readExternalMcpSilentSessions, readExternalMcpSilentSessions,
readExternalMcpStoredEnabled, readExternalMcpStoredEnabled,
@@ -42,7 +25,6 @@ import {
syncExternalMcpStartupState, syncExternalMcpStartupState,
syncExternalMcpStartupStateOnce, syncExternalMcpStartupStateOnce,
waitForExternalMcpStartupReady, waitForExternalMcpStartupReady,
writeExternalMcpApprovalAlertMode,
writeExternalMcpFocusOnHostOpen, writeExternalMcpFocusOnHostOpen,
writeExternalMcpSilentSessions, writeExternalMcpSilentSessions,
} from './useExternalMcpToggleState.ts'; } from './useExternalMcpToggleState.ts';
@@ -87,22 +69,6 @@ describe('useExternalMcpToggleState helpers', () => {
assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60); assert.equal(normalizeExternalMcpIdleTimeoutMinutes(99999), 24 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60); assert.equal(normalizeSessionIdleTimeoutMinutes(null), 8 * 60);
assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1); assert.equal(normalizeSessionIdleTimeoutMinutes(0), 1);
assert.equal(normalizeExternalMcpApprovalAlertMode(null), 'unfocused');
assert.equal(normalizeExternalMcpApprovalAlertMode('always'), 'always');
assert.equal(normalizeExternalMcpApprovalAlertMode('nope'), 'unfocused');
});
it('stores the approval alert preference and defaults to unfocused', () => {
const restore = installMemoryLocalStorage();
try {
assert.equal(readExternalMcpApprovalAlertMode(), 'unfocused');
assert.equal(writeExternalMcpApprovalAlertMode('off'), 'off');
assert.equal(readExternalMcpApprovalAlertMode(), 'off');
assert.equal(writeExternalMcpApprovalAlertMode('always'), 'always');
assert.equal(readExternalMcpApprovalAlertMode(), 'always');
} finally {
restore();
}
}); });
for (const savedMinutes of [null, 30]) { for (const savedMinutes of [null, 30]) {
@@ -120,12 +86,11 @@ describe('useExternalMcpToggleState helpers', () => {
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => { externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
received.push(config.sessionIdleTimeoutMinutes); received.push(config.sessionIdleTimeoutMinutes);
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes); manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
return { ok: true };
}, },
}; };
await syncExternalMcpStartupState(bridge); await syncExternalMcpStartupState(bridge);
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout'); assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
await syncExternalMcpConfig(bridge); syncExternalMcpConfig(bridge);
assert.deepEqual(received, [expected, expected]); assert.deepEqual(received, [expected, expected]);
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout'); assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
assert.equal( assert.equal(
@@ -430,361 +395,3 @@ 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();
}
});
});
+26 -390
View File
@@ -1,31 +1,19 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { import {
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN, 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_IDLE_TIMEOUT_MINUTES,
STORAGE_KEY_AI_EXTERNAL_MCP_MODE, STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION,
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS, STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
STORAGE_KEY_AI_PERMISSION_MODE, STORAGE_KEY_AI_PERMISSION_MODE,
STORAGE_KEY_AI_PRIVACY,
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
} from '../../infrastructure/config/storageKeys'; } from '../../infrastructure/config/storageKeys';
import type { AIPermissionMode } from '../../infrastructure/ai/types'; import type { AIPermissionMode } from '../../infrastructure/ai/types';
import { DEFAULT_EXTERNAL_MCP_HTTP_PORT } from '../../domain/externalMcpHttp';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge'; import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents'; import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
export type ExternalMcpMode = 'temporary' | 'persistent'; export type ExternalMcpMode = 'temporary' | 'persistent';
export type ExternalMcpApprovalAlertMode = 'off' | 'unfocused' | 'always';
export const DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE: ExternalMcpApprovalAlertMode = 'unfocused';
const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10; const DEFAULT_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 10;
const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1; const MIN_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES = 1;
@@ -41,7 +29,6 @@ let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
let externalMcpStartupReadyWaitResolve: (() => void) | null = null; let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
/** Bumps on every intentional enable/disable so stale startup applies lose. */ /** Bumps on every intentional enable/disable so stale startup applies lose. */
let externalMcpEnableGeneration = 0; let externalMcpEnableGeneration = 0;
let externalMcpConfigError: string | null = null;
function getWindowHash(hash?: string): string { function getWindowHash(hash?: string): string {
if (typeof hash === 'string') return hash; if (typeof hash === 'string') return hash;
@@ -79,7 +66,6 @@ export function resetExternalMcpStartupReadyForTests(): void {
externalMcpStartupReadyWaitPromise = null; externalMcpStartupReadyWaitPromise = null;
externalMcpStartupReadyWaitResolve = null; externalMcpStartupReadyWaitResolve = null;
externalMcpEnableGeneration = 0; externalMcpEnableGeneration = 0;
externalMcpConfigError = null;
externalMcpStartupSyncPromise = null; externalMcpStartupSyncPromise = null;
} }
@@ -114,157 +100,10 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
return externalMcpStartupReadyWaitPromise; 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 = { type ExternalMcpConfig = {
mode?: ExternalMcpMode; mode: ExternalMcpMode;
idleTimeoutMinutes?: number; idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes?: 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 = { type ExternalMcpBridge = {
@@ -284,7 +123,6 @@ export type ExternalMcpStartupSyncPlan = {
runtimeEnabled: boolean; runtimeEnabled: boolean;
storedEnabled: boolean; storedEnabled: boolean;
shouldPersistStoredEnabled: boolean; shouldPersistStoredEnabled: boolean;
configError?: string;
}; };
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode { export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
@@ -345,26 +183,6 @@ export function writeExternalMcpFocusOnHostOpen(focusOnHostOpen: boolean): void
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN); emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN);
} }
export function normalizeExternalMcpApprovalAlertMode(value: string | null): ExternalMcpApprovalAlertMode {
return value === 'off' || value === 'always' || value === 'unfocused'
? value
: DEFAULT_EXTERNAL_MCP_APPROVAL_ALERT_MODE;
}
/** System notification preference for confirm-mode External MCP approvals. */
export function readExternalMcpApprovalAlertMode(): ExternalMcpApprovalAlertMode {
return normalizeExternalMcpApprovalAlertMode(
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT),
);
}
export function writeExternalMcpApprovalAlertMode(mode: ExternalMcpApprovalAlertMode): ExternalMcpApprovalAlertMode {
const normalized = normalizeExternalMcpApprovalAlertMode(mode);
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT, normalized);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT);
return normalized;
}
/** Whether host_open sessions stay hidden from the tab bar. Defaults to false (existing behavior). */ /** Whether host_open sessions stay hidden from the tab bar. Defaults to false (existing behavior). */
export function readExternalMcpSilentSessions(): boolean { export function readExternalMcpSilentSessions(): boolean {
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS) ?? false; return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS) ?? false;
@@ -399,109 +217,6 @@ export function writeSessionIdleTimeoutMinutes(minutes: number): number {
return normalized; 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({ export function shouldStartExternalMcpOnStartup({
enabled, enabled,
mode, mode,
@@ -524,21 +239,11 @@ export function createExternalMcpStartupSyncPlan({
mode, mode,
idleTimeoutMinutes, idleTimeoutMinutes,
sessionIdleTimeoutMinutes, sessionIdleTimeoutMinutes,
redaction = defaultAIPrivacySettings(),
http = {
enabled: false,
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
allowCustomBind: false,
listenAddress: '0.0.0.0',
clientHost: '',
},
}: { }: {
enabled: boolean; enabled: boolean;
mode: ExternalMcpMode; mode: ExternalMcpMode;
idleTimeoutMinutes: number; idleTimeoutMinutes: number;
sessionIdleTimeoutMinutes: number; sessionIdleTimeoutMinutes: number;
http?: ExternalMcpHttpSettings;
redaction?: AIPrivacySettings;
}): ExternalMcpStartupSyncPlan { }): ExternalMcpStartupSyncPlan {
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode }); const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
const storedEnabled = runtimeEnabled; const storedEnabled = runtimeEnabled;
@@ -547,8 +252,6 @@ export function createExternalMcpStartupSyncPlan({
mode, mode,
idleTimeoutMinutes, idleTimeoutMinutes,
sessionIdleTimeoutMinutes, sessionIdleTimeoutMinutes,
redaction,
...httpConfigFromSettings(http),
}, },
runtimeEnabled, runtimeEnabled,
storedEnabled, storedEnabled,
@@ -557,44 +260,20 @@ export function createExternalMcpStartupSyncPlan({
} }
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan { export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
let redaction = defaultAIPrivacySettings(); return createExternalMcpStartupSyncPlan({
let configError: string | undefined;
try {
redaction = readAIPrivacySettings();
} catch (error) {
configError = error instanceof Error ? error.message : String(error);
}
const plan = createExternalMcpStartupSyncPlan({
enabled: readExternalMcpStoredEnabled(), enabled: readExternalMcpStoredEnabled(),
mode: readExternalMcpMode(), mode: readExternalMcpMode(),
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(), idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(), sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
http: readExternalMcpHttpSettings(),
redaction,
}); });
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
} }
export async function syncExternalMcpConfig( export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(), void bridge?.externalMcpSetConfig?.({
): Promise<void> { mode: readExternalMcpMode(),
const plan = readExternalMcpStartupSyncPlan(); idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
if (plan.configError) { sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
// 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.');
}
} }
/** /**
@@ -616,18 +295,10 @@ export async function syncExternalMcpStartupState(
} catch { } catch {
// Permission sync is best-effort; the main process keeps its safe default. // Permission sync is best-effort; the main process keeps its safe default.
} }
let configError = initialPlan.configError;
try { try {
if (configError) { await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
// Explicit invalidation is distinct from a rejected draft Apply, which keeps } catch {
// the previous working policy. Never replace corrupt saved data with defaults. // Config sync is best-effort; continue with enable/disable reconcile.
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. // A user toggle during config await invalidates this startup apply.
@@ -636,18 +307,6 @@ export async function syncExternalMcpStartupState(
} }
const plan = readExternalMcpStartupSyncPlan(); 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) { if (plan.shouldPersistStoredEnabled) {
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled); localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED); emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
@@ -699,47 +358,24 @@ export function useExternalMcpToggleState() {
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED); emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
}, []); }, []);
const [error, setError] = useState<string | null>(() => externalMcpConfigError); const setEnabled = useCallback((nextEnabled: boolean) => {
persistEnabled(nextEnabled);
const setEnabled = useCallback(async (nextEnabled: boolean) => { // Peer session windows can mirror the stored switch, but they must not own
// Peer session windows mirror storage, but never own the main lifecycle. // the main-process External MCP lifecycle.
if (isPeerSessionWindow) { if (isPeerSessionWindow) return;
persistEnabled(nextEnabled); externalMcpEnableGeneration += 1;
return; // 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
if (!nextEnabled) persistEnabled(false); // event, which can still observe the previous enabled state.
const generation = ++externalMcpEnableGeneration; void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
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); emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
} });
}, [isPeerSessionWindow, persistEnabled]); }, [isPeerSessionWindow, persistEnabled]);
useEffect(() => { useEffect(() => {
const syncFromStorage = () => { const syncFromStorage = () => {
const nextEnabled = readExternalMcpStoredEnabled(); const nextEnabled = readExternalMcpStoredEnabled();
setEnabledRaw(nextEnabled); setEnabledRaw(nextEnabled);
setError(externalMcpConfigError);
}; };
const handleStorage = (event: StorageEvent) => { const handleStorage = (event: StorageEvent) => {
@@ -748,7 +384,7 @@ export function useExternalMcpToggleState() {
}; };
const handleLocalStateChanged = (event: Event) => { const handleLocalStateChanged = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key; const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return; if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
syncFromStorage(); syncFromStorage();
}; };
@@ -793,5 +429,5 @@ export function useExternalMcpToggleState() {
}; };
}, [enabled, isPeerSessionWindow, persistEnabled]); }, [enabled, isPeerSessionWindow, persistEnabled]);
return { enabled, setEnabled, error }; return { enabled, setEnabled };
} }
-11
View File
@@ -24,14 +24,6 @@ export interface ProcessInfo {
command: string; command: string;
} }
export interface GpuDeviceStats {
index: number;
name: string;
utilization: number | null; // percent, null when nvidia-smi reports N/A
memoryUsed: number | null; // MiB
memoryTotal: number | null; // MiB
}
export interface ServerStats { export interface ServerStats {
hostname?: string; // Remote hostname hostname?: string; // Remote hostname
osName?: string; // Remote operating system name osName?: string; // Remote operating system name
@@ -45,7 +37,6 @@ export interface ServerStats {
gpuName: string | null; // First GPU name (tooltip) gpuName: string | null; // First GPU name (tooltip)
gpuMemUsed: number | null; // Summed VRAM used in MB gpuMemUsed: number | null; // Summed VRAM used in MB
gpuMemTotal: number | null; // Summed VRAM total in MB gpuMemTotal: number | null; // Summed VRAM total in MB
gpus: GpuDeviceStats[]; // Per-GPU utilization and VRAM
memTotal: number | null; // Total memory in MB memTotal: number | null; // Total memory in MB
memUsed: number | null; // Used memory in MB (excluding buffers/cache) memUsed: number | null; // Used memory in MB (excluding buffers/cache)
memFree: number | null; // Free memory in MB memFree: number | null; // Free memory in MB
@@ -125,7 +116,6 @@ function createEmptyServerStats(): ServerStats {
gpuName: null, gpuName: null,
gpuMemUsed: null, gpuMemUsed: null,
gpuMemTotal: null, gpuMemTotal: null,
gpus: [],
memTotal: null, memTotal: null,
memUsed: null, memUsed: null,
memFree: null, memFree: null,
@@ -228,7 +218,6 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
gpuName: stats.gpuName ?? null, gpuName: stats.gpuName ?? null,
gpuMemUsed: stats.gpuMemUsed ?? null, gpuMemUsed: stats.gpuMemUsed ?? null,
gpuMemTotal: stats.gpuMemTotal ?? null, gpuMemTotal: stats.gpuMemTotal ?? null,
gpus: Array.isArray(stats.gpus) ? stats.gpus : [],
memTotal: stats.memTotal ?? null, memTotal: stats.memTotal ?? null,
memUsed: stats.memUsed ?? null, memUsed: stats.memUsed ?? null,
memFree: stats.memFree ?? null, memFree: stats.memFree ?? null,
-2
View File
@@ -157,7 +157,6 @@ import {
isValidUiThemeId, isValidUiThemeId,
migrateIncomingTerminalFontId, migrateIncomingTerminalFontId,
migrateMaterializedAutoCloseOnExitDefault, migrateMaterializedAutoCloseOnExitDefault,
migrateMaterializedShiftEnterNewlineDefault,
readStoredString, readStoredString,
serializeTerminalSettings, serializeTerminalSettings,
type HostClickBehavior, type HostClickBehavior,
@@ -367,7 +366,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
// One-time reset of the pre-flip materialized autoCloseOnExit: true before // One-time reset of the pre-flip materialized autoCloseOnExit: true before
// it is ingested; afterwards stored values are the user's own and win. // it is ingested; afterwards stored values are the user's own and win.
migrateMaterializedAutoCloseOnExitDefault(); migrateMaterializedAutoCloseOnExitDefault();
migrateMaterializedShiftEnterNewlineDefault();
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS); const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
return normalizeTerminalSettings(stored); return normalizeTerminalSettings(stored);
}); });
+8 -42
View File
@@ -1,5 +1,4 @@
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
import type { import type {
DockerContainerAction, DockerContainerAction,
DockerImageManageAction, DockerImageManageAction,
@@ -95,27 +94,23 @@ export function useSystemManagerBackend() {
return bridge.tmuxAction(options); return bridge.tmuxAction(options);
}, []); }, []);
const listDockerContainers = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => { const listDockerContainers = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get(); const bridge = netcattyBridge.get();
if (!bridge?.listDockerContainers) { if (!bridge?.listDockerContainers) {
return { success: false as const, error: 'listDockerContainers unavailable' }; return { success: false as const, error: 'listDockerContainers unavailable' };
} }
return bridge.listDockerContainers(sessionId, runtime); return bridge.listDockerContainers(sessionId);
}, []); }, []);
const listDockerImages = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => { const listDockerImages = useCallback(async (sessionId: string) => {
const bridge = netcattyBridge.get(); const bridge = netcattyBridge.get();
if (!bridge?.listDockerImages) { if (!bridge?.listDockerImages) {
return { success: false as const, error: 'listDockerImages unavailable' }; return { success: false as const, error: 'listDockerImages unavailable' };
} }
return bridge.listDockerImages(sessionId, runtime); return bridge.listDockerImages(sessionId);
}, []); }, []);
const getDockerStats = useCallback(async (options: { const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
sessionId: string;
ids?: string[];
runtime?: ContainerRuntimeId;
}) => {
const bridge = netcattyBridge.get(); const bridge = netcattyBridge.get();
if (!bridge?.getDockerStats) { if (!bridge?.getDockerStats) {
return { success: false as const, error: 'getDockerStats unavailable' }; return { success: false as const, error: 'getDockerStats unavailable' };
@@ -160,11 +155,7 @@ export function useSystemManagerBackend() {
return bridge.systemServiceAction(options); return bridge.systemServiceAction(options);
}, []); }, []);
const dockerInspect = useCallback(async (options: { const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
sessionId: string;
containerId: string;
runtime?: ContainerRuntimeId;
}) => {
const bridge = netcattyBridge.get(); const bridge = netcattyBridge.get();
if (!bridge?.dockerInspect) { if (!bridge?.dockerInspect) {
return { success: false as const, error: 'dockerInspect unavailable' }; return { success: false as const, error: 'dockerInspect unavailable' };
@@ -172,11 +163,7 @@ export function useSystemManagerBackend() {
return bridge.dockerInspect(options); return bridge.dockerInspect(options);
}, []); }, []);
const dockerImageInspect = useCallback(async (options: { const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
sessionId: string;
imageId: string;
runtime?: ContainerRuntimeId;
}) => {
const bridge = netcattyBridge.get(); const bridge = netcattyBridge.get();
if (!bridge?.dockerImageInspect) { if (!bridge?.dockerImageInspect) {
return { success: false as const, error: 'dockerImageInspect unavailable' }; return { success: false as const, error: 'dockerImageInspect unavailable' };
@@ -189,7 +176,6 @@ export function useSystemManagerBackend() {
containerId: string; containerId: string;
action: DockerContainerAction; action: DockerContainerAction;
newName?: string; newName?: string;
runtime?: ContainerRuntimeId;
}) => { }) => {
const bridge = netcattyBridge.get(); const bridge = netcattyBridge.get();
if (!bridge?.dockerAction) { if (!bridge?.dockerAction) {
@@ -198,10 +184,7 @@ export function useSystemManagerBackend() {
return bridge.dockerAction(options); return bridge.dockerAction(options);
}, []); }, []);
const dockerImageAction = useCallback(async (options: { const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
sessionId: string;
runtime?: ContainerRuntimeId;
} & DockerImageManageAction) => {
const bridge = netcattyBridge.get(); const bridge = netcattyBridge.get();
if (!bridge?.dockerImageAction) { if (!bridge?.dockerImageAction) {
return { success: false as const, error: 'dockerImageAction unavailable' }; return { success: false as const, error: 'dockerImageAction unavailable' };
@@ -209,21 +192,6 @@ export function useSystemManagerBackend() {
return bridge.dockerImageAction(options); 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 ( const openTerminalPopup = useCallback(async (
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0], payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
) => { ) => {
@@ -255,7 +223,6 @@ export function useSystemManagerBackend() {
dockerImageInspect, dockerImageInspect,
dockerAction, dockerAction,
dockerImageAction, dockerImageAction,
kubectl,
openTerminalPopup, openTerminalPopup,
}), [ }), [
probeSystemCapabilities, probeSystemCapabilities,
@@ -278,7 +245,6 @@ export function useSystemManagerBackend() {
dockerImageInspect, dockerImageInspect,
dockerAction, dockerAction,
dockerImageAction, dockerImageAction,
kubectl,
openTerminalPopup, openTerminalPopup,
]); ]);
} }
-5
View File
@@ -15,11 +15,6 @@ test("GroupDetailsPanel treats an empty SSH identity as an explicit setting", ()
assert.equal(hasGroupSshFields({}), false); assert.equal(hasGroupSshFields({}), false);
}); });
test("GroupDetailsPanel keeps the SSH section when X11 forwarding is set", () => {
assert.equal(hasGroupSshFields({ x11Forwarding: true }), true);
assert.equal(hasGroupSshFields({ x11Forwarding: false }), true);
});
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => { test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true); assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true); assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
+1 -8
View File
@@ -102,7 +102,7 @@ export const hasGroupSshFields = (c: Partial<GroupConfig>): boolean =>
c.protocol === 'ssh' || c.protocol === 'ssh' ||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId || c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
c.deviceType !== undefined || c.deviceType !== undefined ||
c.agentForwarding !== undefined || c.x11Forwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined || c.agentForwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined || !!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
Boolean(c.environmentVariables && c.environmentVariables.length > 0) || Boolean(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath || c.moshEnabled !== undefined || !!c.moshServerPath ||
@@ -262,7 +262,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
delete next.identityFilePaths; delete next.identityFilePaths;
delete next.deviceType; delete next.deviceType;
delete next.agentForwarding; delete next.agentForwarding;
delete next.x11Forwarding;
delete next.startupCommand; delete next.startupCommand;
delete next.startupCommandRunMode; delete next.startupCommandRunMode;
delete next.legacyAlgorithms; delete next.legacyAlgorithms;
@@ -500,10 +499,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
if (!parentGroup || groupConfigs.length === 0) return false; if (!parentGroup || groupConfigs.length === 0) return false;
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey; return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
}, [groupConfigs, parentGroup]); }, [groupConfigs, parentGroup]);
const inheritedX11Forwarding = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return false;
return !!resolveGroupDefaults(parentGroup, groupConfigs).x11Forwarding;
}, [groupConfigs, parentGroup]);
const inheritedStartupCommandRunMode = useMemo(() => { const inheritedStartupCommandRunMode = useMemo(() => {
if (!parentGroup || groupConfigs.length === 0) return "paste"; if (!parentGroup || groupConfigs.length === 0) return "paste";
return resolveGroupDefaults(parentGroup, groupConfigs).startupCommandRunMode ?? "paste"; return resolveGroupDefaults(parentGroup, groupConfigs).startupCommandRunMode ?? "paste";
@@ -573,7 +568,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }), ...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }),
...(form.deviceType !== undefined && { deviceType: form.deviceType }), ...(form.deviceType !== undefined && { deviceType: form.deviceType }),
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }), ...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
...(form.x11Forwarding !== undefined && { x11Forwarding: form.x11Forwarding }),
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }), ...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }), ...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }),
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }), ...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
@@ -784,7 +778,6 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
setNewKeyFilePath={setNewKeyFilePath} setNewKeyFilePath={setNewKeyFilePath}
inheritedLegacyAlgorithms={inheritedLegacyAlgorithms} inheritedLegacyAlgorithms={inheritedLegacyAlgorithms}
inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey} inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey}
inheritedX11Forwarding={inheritedX11Forwarding}
inheritedStartupCommandRunMode={inheritedStartupCommandRunMode} inheritedStartupCommandRunMode={inheritedStartupCommandRunMode}
showAlgorithmOverrides={showAlgorithmOverrides} showAlgorithmOverrides={showAlgorithmOverrides}
setShowAlgorithmOverrides={setShowAlgorithmOverrides} setShowAlgorithmOverrides={setShowAlgorithmOverrides}
-11
View File
@@ -47,7 +47,6 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
setNewKeyFilePath, setNewKeyFilePath,
inheritedLegacyAlgorithms, inheritedLegacyAlgorithms,
inheritedSkipEcdsaHostKey, inheritedSkipEcdsaHostKey,
inheritedX11Forwarding,
inheritedStartupCommandRunMode, inheritedStartupCommandRunMode,
showAlgorithmOverrides, showAlgorithmOverrides,
setShowAlgorithmOverrides, setShowAlgorithmOverrides,
@@ -396,16 +395,6 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
onToggle={() => update("agentForwarding", !form.agentForwarding)} onToggle={() => update("agentForwarding", !form.agentForwarding)}
/> />
<ToggleRow
label={t("hostDetails.x11Forwarding")}
hint={t("hostDetails.x11Forwarding.desc")}
enabled={!!(form.x11Forwarding ?? inheritedX11Forwarding)}
onToggle={() => update(
"x11Forwarding",
!(form.x11Forwarding ?? inheritedX11Forwarding),
)}
/>
{/* Startup Command — Textarea so multi-line sequences are typeable {/* Startup Command — Textarea so multi-line sequences are typeable
here just like on the per-host details panel (#1083 follow-up). */} here just like on the per-host details panel (#1083 follow-up). */}
<Textarea <Textarea
@@ -30,17 +30,6 @@ test('enabling SSH agent login clears an imported none sentinel', () => {
assert.match(source, /resolveSshAgentToggleUpdate/); assert.match(source, /resolveSshAgentToggleUpdate/);
}); });
test('X11 forwarding display follows the effective host after group defaults', () => {
assert.match(source, /applyGroupDefaults\(form, effectiveGroupDefaults\)/);
assert.match(source, /const showX11Forwarding = \(!effectiveConnectionHost\.protocol \|\| effectiveConnectionHost\.protocol === "ssh"\)/);
assert.match(source, /!effectiveConnectionHost\.moshEnabled/);
assert.match(source, /!effectiveConnectionHost\.etEnabled/);
assert.match(source, /const effectiveX11Forwarding = !!effectiveConnectionHost\.x11Forwarding/);
assert.match(source, /\{showX11Forwarding && \(/);
assert.match(source, /enabled=\{effectiveX11Forwarding\}/);
assert.doesNotMatch(source, /effectiveGroupDefaults\?\.x11Forwarding/);
});
test('login and forwarding warnings use independent agent status', () => { test('login and forwarding warnings use independent agent status', () => {
assert.match(source, /systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus\.running/); assert.match(source, /systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus\.running/);
assert.match(source, /form\.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus\.running/); assert.match(source, /form\.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus\.running/);
+3 -13
View File
@@ -1,5 +1,4 @@
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host'; import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
import { applyGroupDefaults } from "../domain/groupConfig";
import React from "react"; import React from "react";
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react"; import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
import { customThemeStore } from "../application/state/customThemeStore"; import { customThemeStore } from "../application/state/customThemeStore";
@@ -64,15 +63,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
const systemSshAgentEnabled = effectiveAuthMethod === "auto" const systemSshAgentEnabled = effectiveAuthMethod === "auto"
? form.useSshAgent !== false ? form.useSshAgent !== false
: effectiveAuthMethod === "key" && form.useSshAgent === true; : effectiveAuthMethod === "key" && form.useSshAgent === true;
// Match the connection: inherited X11 is dropped when the effective transport
// is Mosh, Eternal Terminal, or anything other than plain SSH.
const effectiveConnectionHost = effectiveGroupDefaults
? applyGroupDefaults(form, effectiveGroupDefaults)
: form;
const showX11Forwarding = (!effectiveConnectionHost.protocol || effectiveConnectionHost.protocol === "ssh")
&& !effectiveConnectionHost.moshEnabled
&& !effectiveConnectionHost.etEnabled;
const effectiveX11Forwarding = !!effectiveConnectionHost.x11Forwarding;
return ( return (
<> <>
@@ -347,7 +337,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection> </HostDetailsSection>
{/* X11 Forwarding */} {/* X11 Forwarding */}
{showX11Forwarding && ( {(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<HostDetailsSection <HostDetailsSection
icon={<TerminalSquare size={14} className="text-muted-foreground" />} icon={<TerminalSquare size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.x11Forwarding")} title={t("hostDetails.section.x11Forwarding")}
@@ -355,8 +345,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
<ToggleRow <ToggleRow
label={t("hostDetails.x11Forwarding")} label={t("hostDetails.x11Forwarding")}
hint={t("hostDetails.x11Forwarding.desc")} hint={t("hostDetails.x11Forwarding.desc")}
enabled={effectiveX11Forwarding} enabled={!!form.x11Forwarding}
onToggle={() => update("x11Forwarding", !effectiveX11Forwarding)} onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
/> />
</HostDetailsSection> </HostDetailsSection>
)} )}
@@ -2,7 +2,6 @@ import React from "react";
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-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 type { Host } from "../types";
import { applyHostAuthMethodSelection } from "../domain/sshAuth"; import { applyHostAuthMethodSelection } from "../domain/sshAuth";
import { HostTotpFields } from "./HostTotpFields";
import { HostIconPicker } from "./HostIconPicker"; import { HostIconPicker } from "./HostIconPicker";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Combobox } from "./ui/combobox"; import { Combobox } from "./ui/combobox";
@@ -102,7 +101,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
distroOptions, distroOptions,
effectiveFormDistro, effectiveFormDistro,
getDistroOptionLabel, getDistroOptionLabel,
totpFlushRef,
}) => { }) => {
const selectAuthMethod = (authMethod: "auto" | "password" | "key" | "certificate") => { const selectAuthMethod = (authMethod: "auto" | "password" | "key" | "certificate") => {
setForm((previous: Host) => applyEffectiveHostAuthMethodSelection( setForm((previous: Host) => applyEffectiveHostAuthMethodSelection(
@@ -741,13 +739,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
</div> </div>
</div> </div>
)} )}
<HostTotpFields
t={t}
form={form}
setForm={setForm}
effectiveProtocol={effectiveProtocol}
flushRef={totpFlushRef}
/>
</div> </div>
</HostDetailsSection> </HostDetailsSection>
@@ -92,7 +92,7 @@ test("HostDetailsPanel labels command proxy summaries consistently", () => {
assert.match(markup, /ProxyCommand/); assert.match(markup, /ProxyCommand/);
assert.doesNotMatch(markup, /COMMAND/); assert.doesNotMatch(markup, /COMMAND/);
assert.doesNotMatch(markup, /cloudflared access ssh/); assert.doesNotMatch(markup, /cloudflared access ssh/);
assert.doesNotMatch(markup, /--token secret/); assert.doesNotMatch(markup, /secret/);
}); });
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => { test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
+1 -17
View File
@@ -44,7 +44,6 @@ import {
} from "./ui/aside-panel"; } from "./ui/aside-panel";
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections"; import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections"; import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
import type { HostTotpFlushResult } from "./HostTotpFields";
import { import {
LINUX_DISTRO_OPTION_IDS, LINUX_DISTRO_OPTION_IDS,
parseOptionalPortInput, parseOptionalPortInput,
@@ -164,8 +163,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
}; };
const { checkSshAgent } = useApplicationBackend(); const { checkSshAgent } = useApplicationBackend();
const baselineSnippetsRef = useRef(snippets); const baselineSnippetsRef = useRef(snippets);
const totpFlushRef = useRef<(() => Promise<HostTotpFlushResult>) | null>(null);
const savingHostRef = useRef(false);
const baselineHydratedRef = useRef(snippets.length > 0); const baselineHydratedRef = useRef(snippets.length > 0);
const openingQueueIdsRef = useRef<string[]>([]); const openingQueueIdsRef = useRef<string[]>([]);
const [form, setForm] = useState<Host>( const [form, setForm] = useState<Host>(
@@ -537,12 +534,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol)) ? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol))
: hasHostname; : hasHostname;
const handleSubmit = async () => { const handleSubmit = () => {
if (savingHostRef.current) return;
savingHostRef.current = true;
try {
const flushed = await totpFlushRef.current?.();
if (flushed && !flushed.ok) return;
const hostname = form.hostname.trim() const hostname = form.hostname.trim()
|| (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : ""); || (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : "");
if (!hostname || !canSaveHost) return; if (!hostname || !canSaveHost) return;
@@ -623,10 +615,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
authPolicyVersion: 1, authPolicyVersion: 1,
managedSourceId: finalManagedSourceId, managedSourceId: finalManagedSourceId,
}; };
if (flushed?.ok) {
if (flushed.totp) cleaned.totp = flushed.totp;
else delete cleaned.totp;
}
if (pluginProtocolActive) { if (pluginProtocolActive) {
const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol); const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol);
if (!pluginConnection) return; if (!pluginConnection) return;
@@ -754,9 +742,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
} }
} }
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned)); onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
} finally {
savingHostRef.current = false;
}
}; };
const handleCreateGroup = () => { const handleCreateGroup = () => {
@@ -1152,7 +1137,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
t={t} t={t}
form={form} form={form}
setForm={setForm} setForm={setForm}
totpFlushRef={totpFlushRef}
update={update} update={update}
groupDefaults={effectiveGroupDefaults} groupDefaults={effectiveGroupDefaults}
effectiveAuthMethod={effectiveAuthMethod} effectiveAuthMethod={effectiveAuthMethod}
-104
View File
@@ -1,104 +0,0 @@
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];
}
}
});
-568
View File
@@ -1,568 +0,0 @@
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}
</>
);
};
-1
View File
@@ -235,7 +235,6 @@ 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"[^>]*data-tree-depth="0"/);
assert.match(markup, /data-host-id="host-1"[^>]*aria-level="1"/); 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"[^>]*tabindex="0"/);
assert.match(markup, /data-host-id="host-1"[^>]*draggable="true"/);
}); });
test("HostTreeView exposes only one tree item in the tab order", () => { test("HostTreeView exposes only one tree item in the tab order", () => {
+6 -26
View File
@@ -17,7 +17,6 @@ import {
} from '../domain/hostClickBehavior'; } from '../domain/hostClickBehavior';
import { sortByVaultOrder } from '../domain/vaultOrder'; import { sortByVaultOrder } from '../domain/vaultOrder';
import { getVaultHostRowSubtitle } from '../domain/localShellHost'; import { getVaultHostRowSubtitle } from '../domain/localShellHost';
import { isVaultHostDraggable } from '../domain/vaultHostDrag';
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys'; import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
import { GroupConfig, GroupNode, Host } from '../types'; import { GroupConfig, GroupNode, Host } from '../types';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
@@ -26,8 +25,6 @@ import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
import { DistroAvatar } from './DistroAvatar'; import { DistroAvatar } from './DistroAvatar';
import { HostNotesIndicator } from './host/HostNotesIndicator'; import { HostNotesIndicator } from './host/HostNotesIndicator';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { handleVaultHostDropToGroup } from './vault/vaultReorderDrag';
import { beginVaultHostCardDrag } from './vault/vaultHostDragImage';
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow'; import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
const getTreeGroupDropIntent = ( const getTreeGroupDropIntent = (
@@ -296,7 +293,6 @@ interface HostTreeViewProps {
onEditGroup: (groupPath: string) => void; onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void; onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void; moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void; moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>; commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void; cancelInlineGroupEdit?: () => void;
@@ -338,7 +334,6 @@ interface TreeNodeProps {
onEditGroup: (groupPath: string) => void; onEditGroup: (groupPath: string) => void;
onDeleteGroup: (groupPath: string) => void; onDeleteGroup: (groupPath: string) => void;
moveHostToGroup: (hostId: string, groupPath: string | null) => void; moveHostToGroup: (hostId: string, groupPath: string | null) => void;
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
moveGroup: (sourcePath: string, targetParent: string | null) => void; moveGroup: (sourcePath: string, targetParent: string | null) => void;
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>; commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
cancelInlineGroupEdit?: () => void; cancelInlineGroupEdit?: () => void;
@@ -386,7 +381,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onEditGroup, onEditGroup,
onDeleteGroup, onDeleteGroup,
moveHostToGroup, moveHostToGroup,
moveHostsToGroup,
moveGroup, moveGroup,
managedGroupPaths, managedGroupPaths,
onUnmanageGroup, onUnmanageGroup,
@@ -532,14 +526,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setDragOverDropTarget?.(null); setDragOverDropTarget?.(null);
if (handleVaultHostDropToGroup({ const hostId = e.dataTransfer.getData("host-id");
dataTransfer: e.dataTransfer,
groupPath: node.path,
moveHostToGroup,
moveHostsToGroup,
resetHostDragState: () => {},
})) return;
const groupPath = e.dataTransfer.getData("group-path"); const groupPath = e.dataTransfer.getData("group-path");
if (hostId) moveHostToGroup(hostId, node.path);
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") { if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
moveGroup(groupPath, node.path); moveGroup(groupPath, node.path);
} }
@@ -609,7 +598,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onEditGroup={onEditGroup} onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup} onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup} moveHostToGroup={moveHostToGroup}
moveHostsToGroup={moveHostsToGroup}
moveGroup={moveGroup} moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths} managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup} onUnmanageGroup={onUnmanageGroup}
@@ -786,16 +774,11 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
? 0 ? 0
: -1} : -1}
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)} onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)} draggable={!isMultiSelectMode}
onDragStart={(e) => { onDragStart={(e) => {
const ids = beginVaultHostCardDrag({ // copyMove: vault reorder uses move; focus-sidebar append uses copy.
dataTransfer: e.dataTransfer, e.dataTransfer.effectAllowed = "copyMove";
hostId: host.id, e.dataTransfer.setData("host-id", host.id);
isMultiSelectMode: Boolean(isMultiSelectMode),
selectedHostIds,
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
});
if (!ids) e.preventDefault();
}} }}
onClick={() => { onClick={() => {
const action = resolveHostActivateAction({ const action = resolveHostActivateAction({
@@ -926,7 +909,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
onEditGroup, onEditGroup,
onDeleteGroup, onDeleteGroup,
moveHostToGroup, moveHostToGroup,
moveHostsToGroup,
moveGroup, moveGroup,
managedGroupPaths, managedGroupPaths,
onUnmanageGroup, onUnmanageGroup,
@@ -1237,7 +1219,6 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
onEditGroup={onEditGroup} onEditGroup={onEditGroup}
onDeleteGroup={onDeleteGroup} onDeleteGroup={onDeleteGroup}
moveHostToGroup={moveHostToGroup} moveHostToGroup={moveHostToGroup}
moveHostsToGroup={moveHostsToGroup}
moveGroup={moveGroup} moveGroup={moveGroup}
managedGroupPaths={managedGroupPaths} managedGroupPaths={managedGroupPaths}
onUnmanageGroup={onUnmanageGroup} onUnmanageGroup={onUnmanageGroup}
@@ -1330,7 +1311,6 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
&& prev.onEditGroup === next.onEditGroup && prev.onEditGroup === next.onEditGroup
&& prev.onDeleteGroup === next.onDeleteGroup && prev.onDeleteGroup === next.onDeleteGroup
&& prev.moveHostToGroup === next.moveHostToGroup && prev.moveHostToGroup === next.moveHostToGroup
&& prev.moveHostsToGroup === next.moveHostsToGroup
&& prev.moveGroup === next.moveGroup && prev.moveGroup === next.moveGroup
&& prev.commitInlineGroupRename === next.commitInlineGroupRename && prev.commitInlineGroupRename === next.commitInlineGroupRename
&& prev.cancelInlineGroupEdit === next.cancelInlineGroupEdit && prev.cancelInlineGroupEdit === next.cancelInlineGroupEdit
+34 -80
View File
@@ -4,94 +4,48 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url); import { formatKeyboardInteractiveServerPrompt } from "./KeyboardInteractiveModal.tsx";
import {
keyboardInteractiveFieldLabel,
keyboardInteractiveGuidance,
totpFillPlacement,
visibleTotpFillPlacements,
} from "./KeyboardInteractiveModal.tsx";
const modalSource = readFileSync(new URL("./KeyboardInteractiveModal.tsx", import.meta.url), "utf8"); const modalSource = readFileSync(new URL("./KeyboardInteractiveModal.tsx", import.meta.url), "utf8");
test("keyboardInteractiveFieldLabel uses the cleaned last prompt line", () => { test("formatKeyboardInteractiveServerPrompt preserves server instructions and prompt labels", () => {
assert.equal(keyboardInteractiveFieldLabel("Secondary Authentication Password:", "Verification code"), "Secondary Authentication Password"); const text = formatKeyboardInteractiveServerPrompt({
assert.equal(keyboardInteractiveFieldLabel("JumpServer:\n| SMS code:", "Verification code"), "SMS code"); hostname: "192.168.9.138",
assert.equal(keyboardInteractiveFieldLabel("[OTP Code]: ", "Verification code"), "OTP Code"); name: "Keyboard-interactive authentication prompts from server",
assert.equal(keyboardInteractiveFieldLabel(" ", "Verification code"), "Verification code"); 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("keyboard-interactive dialog shows a field label and an inline TOTP action", async () => { test("formatKeyboardInteractiveServerPrompt omits hostname-only fallback prompts", () => {
assert.doesNotMatch(modalSource, /<pre/); const text = formatKeyboardInteractiveServerPrompt({
assert.match(modalSource, /keyboardInteractiveFieldLabel\(/); hostname: "192.168.9.138",
assert.match(modalSource, /keyboardInteractiveGuidance\(/); name: "192.168.9.138",
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/);
});
test("keyboardInteractiveGuidance keeps directions that are not already the field label", () => {
assert.equal(keyboardInteractiveGuidance({
name: "bastion",
hostname: "bastion",
instructions: "", instructions: "",
prompts: [{ prompt: "Verification code:", echo: false }], prompts: [
}), ""); {
assert.equal(keyboardInteractiveGuidance({ prompt: "Password:",
name: "Duo Security", echo: false,
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(text, "");
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", () => { test("keyboard-interactive modal cannot be dismissed by outside click or Escape", () => {
+40 -116
View File
@@ -5,7 +5,6 @@
*/ */
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react"; import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
import React, { useCallback, useEffect, useMemo, useState } from "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 { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { import {
@@ -35,54 +34,35 @@ export interface KeyboardInteractiveRequest {
savedPassword?: string | null; savedPassword?: string | null;
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */ /** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
allowSavePassword?: boolean; allowSavePassword?: boolean;
/** otpauth URI from the connection attempt that opened this dialog. */
totpUri?: string;
} }
/** Last meaningful line of a server prompt, without the raw banner. */ type KeyboardInteractiveServerPromptInput = Pick<
export function keyboardInteractiveFieldLabel(prompt: string | undefined, fallback: string): string { KeyboardInteractiveRequest,
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); "name" | "instructions" | "prompts" | "hostname"
const last = lines.at(-1) || ""; >;
const cleaned = last
.replace(/^[\s||>•*-]+/, "")
.replace(/[::]\s*$/, "")
.trim()
.replace(/^\[(.+)\]$/, "$1")
.trim();
return cleaned || fallback;
}
function guidanceKeys(text: string): string[] { /** Formats the server-supplied keyboard-interactive prompt block for display. */
const trimmed = text.trim().toLowerCase(); export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string {
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 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 lines: string[] = [];
const push = (text: string) => { const name = request.name?.trim();
const line = text.trim().replace(/^\|\s*/, ""); const hostname = request.hostname?.trim();
if (!line) return; const instructions = request.instructions?.trim();
const keys = guidanceKeys(line); const hasServerText = !!instructions || !!(name && name !== hostname);
if (keys.some((key) => labelKeys.has(key) || seen.has(key))) return; if (!hasServerText) return "";
for (const key of keys) seen.add(key); if (name && name !== hostname) {
lines.push(line); lines.push(name.endsWith(":") ? name : `${name}:`);
}; }
const hostname = (request.hostname || "").trim().toLowerCase(); if (instructions) {
const name = (request.name || "").trim(); for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) {
if (name && name.toLowerCase() !== hostname) push(name); lines.push(`| ${line}`);
for (const part of String(request.instructions || "").split(/\r?\n/)) push(part); }
for (const part of promptLines) push(part); }
for (const prompt of request.prompts || []) {
const promptText = prompt.prompt?.trim();
if (promptText) {
lines.push(`| ${promptText}`);
}
}
return lines.join("\n"); return lines.join("\n");
} }
@@ -127,43 +107,12 @@ interface KeyboardInteractiveModalProps {
request: KeyboardInteractiveRequest | null; request: KeyboardInteractiveRequest | null;
onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>; onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>;
onCancel: (requestId: 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> = ({ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
request, request,
onSubmit, onSubmit,
onCancel, onCancel,
canFillTotp = false,
onFillTotp,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const [responses, setResponses] = useState<string[]>([]); const [responses, setResponses] = useState<string[]>([]);
@@ -236,15 +185,6 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
} }
}, [request, onCancel, isSubmitting]); }, [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( const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => { (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isSubmitting) { if (e.key === "Enter" && !isSubmitting) {
@@ -257,14 +197,12 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
if (!request) return null; if (!request) return null;
const serverPromptText = formatKeyboardInteractiveServerPrompt(request);
const title = t("keyboard.interactive.title"); const title = t("keyboard.interactive.title");
const description = const description =
request.hostname request.hostname
? t("keyboard.interactive.descWithHost", { hostname: request.hostname }) ? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
: t("keyboard.interactive.desc"); : t("keyboard.interactive.desc");
const fillPlacements = canFillTotp ? visibleTotpFillPlacements(request.prompts) : [];
const preferredFillIndex = fillPlacements.findIndex((placement) => placement === "preferred");
const guidance = keyboardInteractiveGuidance(request);
return ( return (
<Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}> <Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}>
@@ -289,40 +227,26 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
{guidance ? ( {serverPromptText && (
<p className="max-h-36 overflow-auto whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground [overflow-wrap:anywhere]"> <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]">
{guidance} {serverPromptText}
</p> </pre>
) : null} )}
{request.prompts.map((prompt, index) => { {request.prompts.map((prompt, index) => {
const isPassword = !prompt.echo; const isPassword = !prompt.echo;
const showPassword = showPasswords[index]; const showPassword = showPasswords[index];
const promptLabel = keyboardInteractiveFieldLabel( const showPromptLabel = !(serverPromptText && request.prompts.length === 1);
prompt.prompt, // Clean up prompt text (remove trailing colon and whitespace)
t("keyboard.interactive.verificationCode"), const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
);
const fillPlacement = fillPlacements[index] ?? "hidden";
return ( return (
<div key={index} className="space-y-2"> <div key={index} className="space-y-2">
<div className="flex items-center justify-between gap-2"> {showPromptLabel && (
<Label htmlFor={`ki-prompt-${index}`}> <Label htmlFor={`ki-prompt-${index}`}>
{promptLabel} {promptLabel || t("keyboard.interactive.response")}
</Label> </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"> <div className="relative">
<Input <Input
id={`ki-prompt-${index}`} id={`ki-prompt-${index}`}
@@ -332,7 +256,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="" placeholder=""
className={isPassword ? "pr-10" : undefined} className={isPassword ? "pr-10" : undefined}
autoFocus={preferredFillIndex >= 0 ? index === preferredFillIndex : index === 0} autoFocus={index === 0}
disabled={isSubmitting} disabled={isSubmitting}
/> />
{isPassword && ( {isPassword && (
@@ -364,7 +288,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
</div> </div>
); );
})} })}
</div> </div>
<div className="flex items-center justify-between pt-2"> <div className="flex items-center justify-between pt-2">
<Button <Button
-2
View File
@@ -4,7 +4,6 @@ import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig"
import { sanitizeCredentialValue } from "../domain/credentials"; import { sanitizeCredentialValue } from "../domain/credentials";
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth"; import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts"; import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
import { connectionTotpFields } from "../domain/totpConfig";
import { cn } from "../lib/utils"; import { cn } from "../lib/utils";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
@@ -259,7 +258,6 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000, sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
enableKeyboardInteractive: true, enableKeyboardInteractive: true,
requiresMfa: !!effectiveExportHost.requiresMfa, requiresMfa: !!effectiveExportHost.requiresMfa,
...connectionTotpFields(effectiveExportHost),
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`, sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
}); });
+1 -9
View File
@@ -10,7 +10,6 @@ import {
User, User,
} from "lucide-react"; } from "lucide-react";
import React, { useMemo, useState } from "react"; import React, { useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useI18n } from "../application/i18n/I18nProvider"; import { useI18n } from "../application/i18n/I18nProvider";
import type { QuickConnectTarget } from "../domain/quickConnect"; import type { QuickConnectTarget } from "../domain/quickConnect";
import { formatHostPort } from "../domain/host"; import { formatHostPort } from "../domain/host";
@@ -672,7 +671,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
: `${protocolLabel} ${hostEndpoint}` : `${protocolLabel} ${hostEndpoint}`
); );
const wizard = ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center" className="fixed inset-0 z-50 flex items-center justify-center"
onClick={onClose} onClick={onClose}
@@ -799,13 +798,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
</div> </div>
</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; export default QuickConnectWizard;
@@ -1,163 +0,0 @@
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, />新建工作区</);
});
+9 -51
View File
@@ -8,14 +8,12 @@ import {
Search, Search,
Terminal, Terminal,
TerminalSquare, TerminalSquare,
Zap,
} from "lucide-react"; } from "lucide-react";
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider"; import { useI18n } from "../application/i18n/I18nProvider";
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types"; import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
import { KeyBinding } from "../domain/models"; import { KeyBinding } from "../domain/models";
import type { PaneMagnificationState } from "../domain/paneMagnification"; import type { PaneMagnificationState } from "../domain/paneMagnification";
import { isSwitcherQuickConnectInput } from "../domain/quickConnect";
import { matchesSearchQuery } from "../lib/searchMatcher"; import { matchesSearchQuery } from "../lib/searchMatcher";
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells"; import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
import { usePluginContributions } from "../application/state/usePluginContributions"; import { usePluginContributions } from "../application/state/usePluginContributions";
@@ -32,12 +30,11 @@ type QuickSwitcherItemBase = {
shortcut?: string; shortcut?: string;
pluginId?: string; pluginId?: string;
icon?: NetcattyPluginIconReference; icon?: NetcattyPluginIconReference;
quickConnectInput?: string;
}; };
type QuickSwitcherItem = QuickSwitcherItemBase & ( type QuickSwitcherItem = QuickSwitcherItemBase & (
| { type: "plugin-command"; commandId: string } | { type: "plugin-command"; commandId: string }
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view" | "quick-connect"; commandId?: never } | { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
); );
export function buildPaneMagnificationPaletteAction( export function buildPaneMagnificationPaletteAction(
@@ -168,7 +165,6 @@ interface QuickSwitcherProps {
paneMagnificationState?: PaneMagnificationState; paneMagnificationState?: PaneMagnificationState;
onMagnifyCurrentPane?: () => void; onMagnifyCurrentPane?: () => void;
onRestoreMagnifiedPane?: () => void; onRestoreMagnifiedPane?: () => void;
onQuickConnect?: (input: string) => void;
} }
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
@@ -190,7 +186,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
paneMagnificationState = 'unavailable', paneMagnificationState = 'unavailable',
onMagnifyCurrentPane, onMagnifyCurrentPane,
onRestoreMagnifiedPane, onRestoreMagnifiedPane,
onQuickConnect,
}) => { }) => {
const { t } = useI18n(); const { t } = useI18n();
const discoveredShells = useDiscoveredShells(); const discoveredShells = useDiscoveredShells();
@@ -340,37 +335,19 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
}); });
}; };
const quickConnectItem: QuickSwitcherItem | null = if (results.length > 0) {
trimmedQuery && isSwitcherQuickConnectInput(trimmedQuery) pushHeader("header:hosts", "Hosts");
? { results.forEach((host) =>
type: "quick-connect", pushItem({ type: "host", id: host.id, data: host }),
id: "quick-connect", );
title: t("qs.quickConnectTo", { target: trimmedQuery }), }
quickConnectInput: trimmedQuery,
}
: null;
const hasTabsSection = const hasTabsSection =
builtInTabs.length > 0 builtInTabs.length > 0
|| filteredOrphanSessions.length > 0 || filteredOrphanSessions.length > 0
|| filteredWorkspaces.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) { if (hasTabsSection) {
pushHeader("header:tabs", t("qs.tabs")); pushHeader("header:tabs", "Tabs");
builtInTabs.forEach((tabId) => { builtInTabs.forEach((tabId) => {
pushItem({ type: "tab", id: tabId }); pushItem({ type: "tab", id: tabId });
}); });
@@ -380,7 +357,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
filteredOrphanSessions.forEach((s) => filteredOrphanSessions.forEach((s) =>
pushItem({ type: "tab", id: s.id, data: s }), pushItem({ type: "tab", id: s.id, data: s }),
); );
if (quickConnectItem && results.length === 0) pushItem(quickConnectItem);
} }
if (filteredShells.length > 0) { if (filteredShells.length > 0) {
@@ -469,10 +445,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
const handleItemSelect = (item: QuickSwitcherItem, useAlternate = false) => { const handleItemSelect = (item: QuickSwitcherItem, useAlternate = false) => {
switch (item.type) { switch (item.type) {
case "quick-connect":
if (item.quickConnectInput) onQuickConnect?.(item.quickConnectInput);
onClose();
break;
case "host": case "host":
onSelect(item.data as Host); onSelect(item.data as Host);
break; break;
@@ -570,7 +542,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" 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} /> <Plus size={11} />
<span>{t("qs.newWorkspace")}</span> <span>New Workspace</span>
</button> </button>
)} )}
</div> </div>
@@ -604,20 +576,6 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
// Fixed virtual slots: keep content single-line (or plugin two-line) and clip overflow. // 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)}`; 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") { if (item.type === "host") {
const host = item.data as Host; const host = item.data as Host;
const hostRow = ( const hostRow = (
+14 -45
View File
@@ -59,7 +59,7 @@ import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPas
import { isPluginHostProtocol } from "../domain/pluginConnection"; import { isPluginHostProtocol } from "../domain/pluginConnection";
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch"; import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
import { import {
foldTerminalPromptSecurityOutput, appendTerminalPromptSecurityTail,
isConfirmedTerminalShellPrompt, isConfirmedTerminalShellPrompt,
isUntrustedTerminalInputPrompt, isUntrustedTerminalInputPrompt,
} from "../domain/terminalPromptSecurity"; } from "../domain/terminalPromptSecurity";
@@ -163,12 +163,8 @@ import {
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast"; import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry"; import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry"; import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
import { import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
isIdleShellReadyForCommandInjection,
isShellPromptReadyForInsertion,
} from "../domain/terminalCommandInjectionReady";
import { detectPrompt } from "./terminal/autocomplete/promptDetector"; import { detectPrompt } from "./terminal/autocomplete/promptDetector";
import type { SensitiveInputActiveRef } from "./terminal/autocomplete/terminalAutocompletePrompt";
import { import {
applyUserCursorPreference, applyUserCursorPreference,
applyUserCursorWidthPreference, applyUserCursorWidthPreference,
@@ -443,7 +439,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const recorder = useScriptRecorder(sessionId); const recorder = useScriptRecorder(sessionId);
const recorderRef = useRef(recorder); const recorderRef = useRef(recorder);
recorderRef.current = recorder; recorderRef.current = recorder;
const passwordPromptActiveRef = useRef(false) as SensitiveInputActiveRef; const passwordPromptActiveRef = useRef(false);
const terminalTitleRef = useRef<string | undefined>(undefined); const terminalTitleRef = useRef<string | undefined>(undefined);
const pluginTerminalLifecycleRef = useRef<ReturnType<typeof usePluginTerminalSessionLifecycle> | null>(null); const pluginTerminalLifecycleRef = useRef<ReturnType<typeof usePluginTerminalSessionLifecycle> | null>(null);
useEffect(() => registerTerminalSensitiveInputReader( useEffect(() => registerTerminalSensitiveInputReader(
@@ -452,8 +448,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
), [sessionId]); ), [sessionId]);
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]); useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
const sensitivePromptOutputTailRef = useRef(""); 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 [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
const dismissedScriptRunIdsRef = useRef(new Set<string>()); const dismissedScriptRunIdsRef = useRef(new Set<string>());
@@ -605,21 +599,18 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture); const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
const onSessionExitRef = useRef(onSessionExit); const onSessionExitRef = useRef(onSessionExit);
const commandBufferRef = useRef<string>(""); const commandBufferRef = useRef<string>("");
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, (options) => { useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
const term = termRef.current; const term = termRef.current;
if (!term) return false; if (!term) return false;
const prompt = detectPrompt(term); const prompt = detectPrompt(term);
const readiness = { return isIdleShellReadyForCommandInjection({
sensitiveInputActive: passwordPromptActiveRef.current, sensitiveInputActive: passwordPromptActiveRef.current,
hasLiveTerminal: true, hasLiveTerminal: true,
alternateScreenActive: isTerminalAlternateScreenActive(term), alternateScreenActive: isTerminalAlternateScreenActive(term),
isAtPrompt: prompt.isAtPrompt, isAtPrompt: prompt.isAtPrompt,
userInputLength: prompt.userInput.length, userInputLength: prompt.userInput.length,
pendingTypedInputLength: commandBufferRef.current.length, pendingTypedInputLength: commandBufferRef.current.length,
}; });
return options?.allowDraft
? isShellPromptReadyForInsertion(readiness)
: isIdleShellReadyForCommandInjection(readiness);
}), [sessionId]); }), [sessionId]);
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState()); const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
const [hasMouseTracking, setHasMouseTracking] = useState(false); const [hasMouseTracking, setHasMouseTracking] = useState(false);
@@ -1704,9 +1695,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
} }
if (typeof payload.passwordPromptActive === "boolean") { if (typeof payload.passwordPromptActive === "boolean") {
passwordPromptActiveRef.current = payload.passwordPromptActive; passwordPromptActiveRef.current = payload.passwordPromptActive;
// The snapshot boolean has no source. A restored true latch stays
// explicit so Email:/Username: cannot bypass a plugin-marked field.
if (payload.passwordPromptActive) passwordPromptActiveRef.source = "explicit";
} }
if (payload.cwd !== undefined) { if (payload.cwd !== undefined) {
const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot"); const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot");
@@ -2013,14 +2001,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
chunk: string, chunk: string,
meta?: TerminalSessionDataMeta, meta?: TerminalSessionDataMeta,
) => { ) => {
const folded = foldTerminalPromptSecurityOutput({ sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail(
origin: heredocPromptOriginRef.current, sensitivePromptOutputTailRef.current,
current: sensitivePromptOutputTailRef.current, chunk,
pendingCr: promptSecurityPendingCrRef.current, );
}, chunk);
heredocPromptOriginRef.current = folded.origin;
sensitivePromptOutputTailRef.current = folded.current;
promptSecurityPendingCrRef.current = folded.pendingCr;
const promptSecurityOptions = { const promptSecurityOptions = {
allowHostStyleGreaterThan: isNetworkDevice, allowHostStyleGreaterThan: isNetworkDevice,
alternateScreen: termRef.current alternateScreen: termRef.current
@@ -2029,30 +2013,23 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}; };
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") { if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput; passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
// Authoritative metadata must not be downgraded to the heuristic latch,
// or trusted labels such as Email: would bypass snippet blocking.
passwordPromptActiveRef.source = meta.pluginPipelineSensitiveInput ? "explicit" : "heuristic";
if (meta.pluginPipelineSensitiveInput) { if (meta.pluginPipelineSensitiveInput) {
autocompleteCloseRef.current?.(); autocompleteCloseRef.current?.();
} else { } else {
sensitivePromptOutputTailRef.current = ""; sensitivePromptOutputTailRef.current = "";
heredocPromptOriginRef.current = "";
promptSecurityPendingCrRef.current = false;
} }
return; return;
} else if (isUntrustedTerminalInputPrompt( } else if (isUntrustedTerminalInputPrompt(
folded.promptText, sensitivePromptOutputTailRef.current,
promptSecurityOptions, promptSecurityOptions,
)) { )) {
passwordPromptActiveRef.current = true; passwordPromptActiveRef.current = true;
passwordPromptActiveRef.source = "heuristic";
autocompleteCloseRef.current?.(); autocompleteCloseRef.current?.();
} else if (isConfirmedTerminalShellPrompt( } else if (isConfirmedTerminalShellPrompt(
folded.promptText, sensitivePromptOutputTailRef.current,
promptSecurityOptions, promptSecurityOptions,
)) { )) {
passwordPromptActiveRef.current = false; passwordPromptActiveRef.current = false;
passwordPromptActiveRef.source = "heuristic";
} }
}, [isNetworkDevice]); }, [isNetworkDevice]);
@@ -2364,8 +2341,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
}), []); }), []);
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null); const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
const archiveHostRef = useRef(host);
archiveHostRef.current = host;
const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([])); const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([]));
const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {}); const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {});
@@ -2455,12 +2430,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const pluginAwareOnCommandCompleted = useCallback(() => { const pluginAwareOnCommandCompleted = useCallback(() => {
pluginTerminalLifecycle.onCommandCompleted(); pluginTerminalLifecycle.onCommandCompleted();
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted(); void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
// Hibernate wake builds its runtime from this callback. Same local }, [pluginTerminalLifecycle]);
// prompt-return refresh as useTerminalEffects (#1952).
if (host.protocol === "local") {
onCommandSubmitted?.("", host.id, host.label, sessionId);
}
}, [host.id, host.label, host.protocol, onCommandSubmitted, pluginTerminalLifecycle, sessionId]);
const pluginAwareOnTerminalCwdChange = useCallback(( const pluginAwareOnTerminalCwdChange = useCallback((
changedSessionId: string, changedSessionId: string,
cwd: string | null, cwd: string | null,
@@ -4361,7 +4331,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
xTermRuntimeContextRef.current = { xTermRuntimeContextRef.current = {
host, host,
getHost: () => archiveHostRef.current,
fontFamilyId, fontFamilyId,
resolvedFontFamily, resolvedFontFamily,
fontSize: effectiveFontSize, fontSize: effectiveFontSize,
@@ -4685,7 +4654,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onWake: wakeFromHibernateRuntime, onWake: wakeFromHibernateRuntime,
}); });
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, 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: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState }); useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, 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: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
return ( return (
<> <>
+3 -9
View File
@@ -6,6 +6,7 @@ import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X,
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { activeTabStore } from '../application/state/activeTabStore'; import { activeTabStore } from '../application/state/activeTabStore';
import { getScriptRecordingSnapshot } from '../application/state/scriptRecordingStore.ts'; import { getScriptRecordingSnapshot } from '../application/state/scriptRecordingStore.ts';
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent'; import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots'; import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots';
import { import {
@@ -113,7 +114,7 @@ import {
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
resolveSessionSidePanelAutoOpen, resolveSessionSidePanelAutoOpen,
} from '../domain/terminalSidePanelAutoOpen'; } from '../domain/terminalSidePanelAutoOpen';
import { canScheduleCommandCwdProbe, shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe'; import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
import { import {
resolvePreferredTerminalCwd, resolvePreferredTerminalCwd,
scheduleBackendCwdProbeAfterCommand, scheduleBackendCwdProbeAfterCommand,
@@ -1347,12 +1348,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const tabId = activeTabIdRef.current; const tabId = activeTabIdRef.current;
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId); const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
// Local POSIX shells do not emit OSC 7. They share this probe so session if (!session || !canReuseTerminalConnection(session)) return;
// restore can `cd` back after restart (#1952). Prompt return calls this
// again with an empty command once the shell is idle, so a slow command
// such as `sleep 1; cd /tmp` is not recorded at the 150ms snapshot.
// SSH keeps the extra-exec eligibility check inside canScheduleCommandCwdProbe.
if (!session || !canScheduleCommandCwdProbe(session)) return;
const sessionHost = sessionHostsMapRef.current.get(sessionId); const sessionHost = sessionHostsMapRef.current.get(sessionId);
const visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp') const visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp')
? sftpHostForTabRef.current.get(tabId) ?? null ? sftpHostForTabRef.current.get(tabId) ?? null
@@ -1376,8 +1372,6 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options), getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
canProbe: async () => { canProbe: async () => {
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false; if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
// Local cwd is the PTY process directory, not an extra SSH channel.
if (session.protocol === "local") return true;
const host = sessionHostsMapRef.current.get(sessionId); const host = sessionHostsMapRef.current.get(sessionId);
if (!host) return false; if (!host) return false;
const hostDeviceClass = classifyDistroId(host.distro); const hostDeviceClass = classifyDistroId(host.distro);
+1 -9
View File
@@ -44,7 +44,6 @@ import React, {
useState, useState,
} from "react"; } from "react";
import { useI18n } from "../application/i18n/I18nProvider"; import { useI18n } from "../application/i18n/I18nProvider";
import { registerQuickConnectOpener } from "../application/quickConnectBridge";
import { useStoredViewMode } from "../application/state/useStoredViewMode"; import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean"; import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useStoredNumber } from "../application/state/useStoredNumber"; import { useStoredNumber } from "../application/state/useStoredNumber";
@@ -562,12 +561,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
[], [],
); );
useEffect(() => registerQuickConnectOpener(({ target, warnings }) => {
setQuickConnectTarget(target);
setQuickConnectWarnings(warnings);
setIsQuickConnectOpen(true);
}), []);
// Protocol select state (for hosts with multiple protocols) // Protocol select state (for hosts with multiple protocols)
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>( const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
null, null,
@@ -1799,8 +1792,7 @@ export const vaultViewAreEqual = (
// Only these terminal connection settings are forwarded to // Only these terminal connection settings are forwarded to
// PortForwarding inside the vault, so compare them directly. Other // PortForwarding inside the vault, so compare them directly. Other
// terminal settings (fonts, themes, etc.) don't affect this subtree // terminal settings (fonts, themes, etc.) don't affect this subtree
// and we don't want to re-render for them. onConnect is also omitted: // and we don't want to re-render for them.
// temporary-connection toggles are read from a ref at connect time.
prev.terminalSettings?.verifyHostKeys === prev.terminalSettings?.verifyHostKeys ===
next.terminalSettings?.verifyHostKeys && next.terminalSettings?.verifyHostKeys &&
prev.terminalSettings?.keepaliveInterval === prev.terminalSettings?.keepaliveInterval ===
-23
View File
@@ -47,30 +47,7 @@ test("plugin importer merge and commit workflow is owned by application state",
assert.match(pluginImporterCommitHookSource, /buildPluginImporterSafePreview/); 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", () => { test("Vault import entry is disabled while an import is running", () => {
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/); assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/); 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"/);
});
+3 -28
View File
@@ -4,7 +4,7 @@
* even when the Catty AI side panel has never been opened. * even when the Catty AI side panel has never been opened.
*/ */
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { ToolCall } from '../ai-elements/tool-call'; import { ToolCall } from '../ai-elements/tool-call';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { import {
@@ -20,13 +20,6 @@ import {
resolveCapabilityId, resolveCapabilityId,
} from '../../infrastructure/ai/harness/permissionGrants'; } from '../../infrastructure/ai/harness/permissionGrants';
import { useI18n } from '../../application/i18n/I18nProvider'; import { useI18n } from '../../application/i18n/I18nProvider';
import { AI_STATE_CHANGED_EVENT } from '../../application/state/aiStateEvents';
import {
externalMcpApprovalToolLabel,
notifyExternalMcpApproval,
publishExternalMcpApprovalAlertMode,
} from '../../application/state/externalMcpApprovalAlert';
import { STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT } from '../../infrastructure/config/storageKeys';
const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__'; const EXTERNAL_MCP_CHAT_SESSION_ID = '__external_mcp__';
@@ -38,37 +31,19 @@ function isExternalMcpApproval(request: ApprovalRequest): boolean {
export const ExternalMcpApprovalsHost: React.FC = () => { export const ExternalMcpApprovalsHost: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map()); const [pendingApprovals, setPendingApprovals] = useState<Map<string, ApprovalRequest>>(new Map());
const alertedIdsRef = useRef(new Set<string>());
useEffect(() => {
const pushMode = () => {
publishExternalMcpApprovalAlertMode();
};
pushMode();
const onChange = (event: Event) => {
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
if (key === STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT) pushMode();
};
window.addEventListener(AI_STATE_CHANGED_EVENT, onChange);
return () => window.removeEventListener(AI_STATE_CHANGED_EVENT, onChange);
}, []);
useEffect(() => { useEffect(() => {
const handler = (request: ApprovalRequest) => { const handler = (request: ApprovalRequest) => {
if (!isExternalMcpApproval(request)) return; if (!isExternalMcpApproval(request)) return;
setPendingApprovals((prev) => new Map(prev).set(request.toolCallId, request)); setPendingApprovals((prev) => new Map(prev).set(request.toolCallId, request));
if (alertedIdsRef.current.has(request.toolCallId)) return;
alertedIdsRef.current.add(request.toolCallId);
notifyExternalMcpApproval(request, t);
}; };
const unsub = onApprovalRequest(handler); const unsub = onApprovalRequest(handler);
replayPendingApprovals(handler); replayPendingApprovals(handler);
return unsub; return unsub;
}, [t]); }, []);
useEffect(() => { useEffect(() => {
return onApprovalCleared((clearedIds) => { return onApprovalCleared((clearedIds) => {
for (const id of clearedIds) alertedIdsRef.current.delete(id);
setPendingApprovals((prev) => { setPendingApprovals((prev) => {
const next = new Map(prev); const next = new Map(prev);
for (const id of clearedIds) next.delete(id); for (const id of clearedIds) next.delete(id);
@@ -154,7 +129,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
{entries.map(([id, req]) => ( {entries.map(([id, req]) => (
<div key={id} className="space-y-1.5"> <div key={id} className="space-y-1.5">
<ToolCall <ToolCall
name={externalMcpApprovalToolLabel(req.toolName)} name={req.toolName}
args={req.args} args={req.args}
approvalTarget={req.target} approvalTarget={req.target}
isLoading={false} isLoading={false}
@@ -51,7 +51,6 @@ import { CopilotCliCard } from "./ai/CopilotCliCard";
import { CodebuddyCard } from "./ai/CodebuddyCard"; import { CodebuddyCard } from "./ai/CodebuddyCard";
import { SafetySettings } from "./ai/SafetySettings"; import { SafetySettings } from "./ai/SafetySettings";
import { ExternalMcpCard } from "./ai/ExternalMcpCard"; import { ExternalMcpCard } from "./ai/ExternalMcpCard";
import { AIPrivacySection } from "./ai/AIPrivacySection";
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance"; import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings"; import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState"; import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
@@ -1298,8 +1297,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</SettingCard> </SettingCard>
</SettingsSection> </SettingsSection>
<AIPrivacySection />
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}> <SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
<ExternalMcpCard /> <ExternalMcpCard />
</SettingsSection> </SettingsSection>
@@ -998,26 +998,6 @@ function SettingsTerminalTab(props: {
onChange={(v) => updateTerminalSetting("verifyHostKeys", v)} onChange={(v) => updateTerminalSetting("verifyHostKeys", v)}
/> />
</SettingRow> </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 <SettingRow
anchorId="terminal-ssh-auto-reconnect" anchorId="terminal-ssh-auto-reconnect"
label={t("settings.terminal.connection.sshAutoReconnectEnabled")} label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
@@ -224,9 +224,9 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
description={t("settings.terminal.behavior.shiftEnterNewlineText.desc")} description={t("settings.terminal.behavior.shiftEnterNewlineText.desc")}
> >
<Input <Input
value={terminalSettings.shiftEnterNewlineText ?? "\\e\\r"} value={terminalSettings.shiftEnterNewlineText ?? "\\n"}
onChange={(e) => updateTerminalSetting("shiftEnterNewlineText", e.target.value)} onChange={(e) => updateTerminalSetting("shiftEnterNewlineText", e.target.value)}
placeholder="\\e\\r" placeholder="\\n"
className="w-56 font-mono" className="w-56 font-mono"
spellCheck={false} spellCheck={false}
/> />
@@ -1,183 +0,0 @@
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();
}
});
@@ -1,105 +0,0 @@
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>
);
};
@@ -1,62 +0,0 @@
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>
);
};
+64 -102
View File
@@ -1,31 +1,17 @@
import React, { useCallback, useEffect, useMemo, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { HelpCircle, RefreshCw } from "lucide-react"; import { Check, Copy, HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider"; import { useI18n } from "../../../../application/i18n/I18nProvider";
import { import {
readExternalMcpApprovalAlertMode,
readExternalMcpFocusOnHostOpen, readExternalMcpFocusOnHostOpen,
readExternalMcpIdleTimeoutMinutes, readExternalMcpIdleTimeoutMinutes,
readExternalMcpMode, readExternalMcpMode,
readExternalMcpSilentSessions, readExternalMcpSilentSessions,
readSessionIdleTimeoutMinutes, readSessionIdleTimeoutMinutes,
writeExternalMcpApprovalAlertMode,
writeExternalMcpFocusOnHostOpen, writeExternalMcpFocusOnHostOpen,
writeExternalMcpIdleTimeoutMinutes, writeExternalMcpIdleTimeoutMinutes,
writeExternalMcpMode, writeExternalMcpMode,
writeExternalMcpSilentSessions, writeExternalMcpSilentSessions,
writeSessionIdleTimeoutMinutes, writeSessionIdleTimeoutMinutes,
readExternalMcpHttpAllowCustomBind,
readExternalMcpHttpClientHost,
readExternalMcpHttpEnabled,
readExternalMcpHttpListenAddress,
readExternalMcpHttpPort,
writeExternalMcpHttpAllowCustomBind,
writeExternalMcpHttpClientHost,
writeExternalMcpHttpEnabled,
writeExternalMcpHttpListenAddress,
writeExternalMcpHttpPort,
type ExternalMcpApprovalAlertMode,
type ExternalMcpHttpSettings,
type ExternalMcpMode, type ExternalMcpMode,
useExternalMcpToggleState, useExternalMcpToggleState,
} from "../../../../application/state/useExternalMcpToggleState"; } from "../../../../application/state/useExternalMcpToggleState";
@@ -33,14 +19,70 @@ import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button"; import { Button } from "../../../ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui"; import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
import { getBridge } from "./types"; import { getBridge } from "./types";
type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor" | "other"; type ExternalMcpClient = "codex" | "claude" | "grok" | "cursor" | "other";
const CLIENT_TABS: 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 = { type ExternalMcpStatus = {
ok: boolean; ok: boolean;
enabled?: boolean; enabled?: boolean;
@@ -55,7 +97,6 @@ type ExternalMcpStatus = {
sessionIdleTimeoutMinutes?: number; sessionIdleTimeoutMinutes?: number;
permissionMode?: string; permissionMode?: string;
error?: string | null; error?: string | null;
http?: ExternalMcpHttpViewStatus | null;
}; };
type ClientSetupStatus = { type ClientSetupStatus = {
@@ -252,23 +293,12 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
export const ExternalMcpCard: React.FC = () => { export const ExternalMcpCard: React.FC = () => {
const { t } = useI18n(); const { t } = useI18n();
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState(); const { enabled, setEnabled } = useExternalMcpToggleState();
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode()); const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes()); const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen()); const [focusOnHostOpen, setFocusOnHostOpenRaw] = useState<boolean>(() => readExternalMcpFocusOnHostOpen());
const [approvalAlert, setApprovalAlertRaw] = useState<ExternalMcpApprovalAlertMode>(
() => readExternalMcpApprovalAlertMode(),
);
const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes()); const [sessionIdleTimeoutMinutes, setSessionIdleTimeoutRaw] = useState<number>(() => readSessionIdleTimeoutMinutes());
const [silentSessions, setSilentSessionsRaw] = useState<boolean>(() => readExternalMcpSilentSessions()); 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 [status, setStatus] = useState<ExternalMcpStatus | null>(null);
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex"); const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null); const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
@@ -314,51 +344,11 @@ export const ExternalMcpCard: React.FC = () => {
writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen); writeExternalMcpFocusOnHostOpen(nextFocusOnHostOpen);
}, []); }, []);
const setApprovalAlert = useCallback((next: ExternalMcpApprovalAlertMode) => {
setApprovalAlertRaw(writeExternalMcpApprovalAlertMode(next));
}, []);
const setSilentSessions = useCallback((nextSilentSessions: boolean) => { const setSilentSessions = useCallback((nextSilentSessions: boolean) => {
setSilentSessionsRaw(nextSilentSessions); setSilentSessionsRaw(nextSilentSessions);
writeExternalMcpSilentSessions(nextSilentSessions); 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 refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
const bridge = getBridge(); const bridge = getBridge();
const includeClients = options?.clients !== false; const includeClients = options?.clients !== false;
@@ -681,7 +671,8 @@ export const ExternalMcpCard: React.FC = () => {
checked={enabled} checked={enabled}
onChange={(nextEnabled) => { onChange={(nextEnabled) => {
setActionMessage(null); setActionMessage(null);
void setEnabled(nextEnabled).then(() => refreshStatus()); setEnabled(nextEnabled);
window.setTimeout(() => { void refreshStatus(); }, 0);
}} }}
/> />
</div> </div>
@@ -707,24 +698,6 @@ export const ExternalMcpCard: React.FC = () => {
</div> </div>
<SettingCard divided className="rounded-md border-border/60 bg-background/70"> <SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingRow
anchorId="ai-external-mcp-approval-alert"
label={t("ai.externalMcp.approvalAlert")}
description={t("ai.externalMcp.approvalAlert.description")}
>
<Select
value={approvalAlert}
options={[
{ value: "always", label: t("ai.externalMcp.approvalAlert.always") },
{ value: "unfocused", label: t("ai.externalMcp.approvalAlert.unfocused") },
{ value: "off", label: t("ai.externalMcp.approvalAlert.off") },
]}
onChange={(value) => setApprovalAlert(
value === "always" || value === "off" ? value : "unfocused",
)}
className="w-40"
/>
</SettingRow>
<SettingRow <SettingRow
label={t("ai.externalMcp.mode")} label={t("ai.externalMcp.mode")}
description={t("ai.externalMcp.mode.description")} description={t("ai.externalMcp.mode.description")}
@@ -805,17 +778,6 @@ export const ExternalMcpCard: React.FC = () => {
</SettingRow> </SettingRow>
</SettingCard> </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="space-y-2">
<div className="flex min-h-8 items-center justify-between gap-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> <div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
@@ -988,8 +950,8 @@ export const ExternalMcpCard: React.FC = () => {
{actionMessage.text} {actionMessage.text}
</div> </div>
) : null} ) : null}
{configError || status?.error ? ( {status?.error ? (
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div> <div className="text-xs text-destructive">{status.error}</div>
) : null} ) : null}
</div> </div>
); );
@@ -1,323 +0,0 @@
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>
);
};
-7
View File
@@ -177,13 +177,6 @@ export interface NetcattyAiBridge {
mode?: 'temporary' | 'persistent'; mode?: 'temporary' | 'persistent';
idleTimeoutMinutes?: number; idleTimeoutMinutes?: number;
sessionIdleTimeoutMinutes?: 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>>; }) => Promise<Record<string, unknown>>;
externalMcpGetUniversalSetupPrompt?: () => Promise<{ externalMcpGetUniversalSetupPrompt?: () => Promise<{
ok: boolean; ok: boolean;
@@ -7,8 +7,7 @@ import type { TerminalSession } from '../../types';
import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types'; import type { DockerContainerAction, DockerContainerInfo, DockerStatInfo, TerminalPopupIcon } from '../../domain/systemManager/types';
import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals'; import { dockerContainerInfoEqual } from '../../domain/systemManager/pollEquals';
import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState'; import { getContainerFlags, getContainerTone } from '../../domain/systemManager/containerState';
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime'; import { buildDockerExecShellCommand, buildDockerLogsCommand } from '../../domain/systemManager/dockerShell';
import { buildContainerExecShellCommand, buildContainerLogsCommand, describeRuntimeFailure } from '../../domain/systemManager/containerRuntime';
import { DockerContainerDetail } from './DockerContainerDetail'; import { DockerContainerDetail } from './DockerContainerDetail';
import { DockerImageIcon } from './DockerImageIcon'; import { DockerImageIcon } from './DockerImageIcon';
import { useStableListOrder, mergePollListByKey } from './listStable'; import { useStableListOrder, mergePollListByKey } from './listStable';
@@ -60,7 +59,6 @@ async function buildContainerPopupIcon(image: string): Promise<TerminalPopupIcon
interface DockerContainersPanelProps { interface DockerContainersPanelProps {
sessionId: string; sessionId: string;
parentSession: TerminalSession; parentSession: TerminalSession;
runtime?: ContainerRuntimeId;
isVisible: boolean; isVisible: boolean;
warmupEnabled?: boolean; warmupEnabled?: boolean;
backend: Backend; backend: Backend;
@@ -159,7 +157,6 @@ const DockerContainerRow = memo(function DockerContainerRow({
export const DockerContainersPanel = memo(function DockerContainersPanel({ export const DockerContainersPanel = memo(function DockerContainersPanel({
sessionId, sessionId,
parentSession, parentSession,
runtime = 'docker',
isVisible, isVisible,
warmupEnabled = false, warmupEnabled = false,
backend, backend,
@@ -182,15 +179,15 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
setConfirmAction(null); setConfirmAction(null);
setPendingAction(null); setPendingAction(null);
setSelectedId(null); setSelectedId(null);
}, [runtime, sessionId]); }, [sessionId]);
const containersFetcher = useCallback(async () => { const containersFetcher = useCallback(async () => {
const result = await backend.listDockerContainers(sessionId, runtime); const result = await backend.listDockerContainers(sessionId);
if (!result.success || !result.containers) { if (!result.success || !result.containers) {
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDocker'))); throw new Error(result.error || stableT('systemManager.errors.loadDocker'));
} }
return result.containers; return result.containers;
}, [backend, runtime, sessionId, stableT]); }, [backend, sessionId, stableT]);
const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000; const listIntervalMs = Math.max(3, listRefreshIntervalSec) * 1000;
const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>( const { data: containers, error, loading, refresh } = usePolling<DockerContainerInfo[]>(
@@ -198,7 +195,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
listIntervalMs, listIntervalMs,
isVisible || warmupEnabled, isVisible || warmupEnabled,
(prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual), (prev, next) => mergePollListByKey(prev, next, (c) => c.id, dockerContainerInfoEqual),
{ poll: isVisible, resetKey: `${sessionId}:${runtime}` }, { poll: isVisible, resetKey: sessionId },
); );
const matched = useMemo<DockerContainerInfo[]>(() => { const matched = useMemo<DockerContainerInfo[]>(() => {
@@ -243,12 +240,12 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
); );
const statsFetcher = useCallback(async () => { const statsFetcher = useCallback(async () => {
if (statContainerIds.length === 0) return []; if (statContainerIds.length === 0) return [];
const result = await backend.getDockerStats({ sessionId, ids: statContainerIds, runtime }); const result = await backend.getDockerStats({ sessionId, ids: statContainerIds });
if (!result.success || !result.stats) { if (!result.success || !result.stats) {
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.loadDockerStats'))); throw new Error(result.error || stableT('systemManager.errors.loadDockerStats'));
} }
return result.stats; return result.stats;
}, [backend, runtime, sessionId, stableT, statContainerIds]); }, [backend, sessionId, stableT, statContainerIds]);
const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000; const statsIntervalMs = Math.max(2, statsRefreshIntervalSec) * 1000;
const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>( const { data: stats, loading: statsLoading } = usePolling<DockerStatInfo[]>(
@@ -269,19 +266,18 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
}, [stats]); }, [stats]);
const getContainerInspectKey = useCallback((container: DockerContainerInfo) => ( const getContainerInspectKey = useCallback((container: DockerContainerInfo) => (
`${sessionId}:${runtime}:${container.id}` `${sessionId}:${container.id}`
), [runtime, sessionId]); ), [sessionId]);
const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => { const fetchContainerInspect = useCallback(async (container: DockerContainerInfo) => {
const result = await backend.dockerInspect({ const result = await backend.dockerInspect({
sessionId, sessionId,
containerId: container.id.slice(0, 12), containerId: container.id.slice(0, 12),
runtime,
}); });
if (!result.success) { if (!result.success) {
throw new Error(describeRuntimeFailure(result, stableT, stableT('systemManager.errors.actionFailed'))); throw new Error(result.error || stableT('systemManager.errors.actionFailed'));
} }
return result.inspect ?? null; return result.inspect ?? null;
}, [backend, runtime, sessionId, stableT]); }, [backend, sessionId, stableT]);
const { const {
records: inspectByContainerId, records: inspectByContainerId,
loadRecord: loadContainerInspect, loadRecord: loadContainerInspect,
@@ -309,12 +305,9 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
} }
setPendingAction({ id: containerId, action }); setPendingAction({ id: containerId, action });
try { try {
const result = await backend.dockerAction({ sessionId, containerId, action, newName, runtime }); const result = await backend.dockerAction({ sessionId, containerId, action, newName });
if (!result.success) { if (!result.success) {
showSystemManagerError( showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
describeRuntimeFailure(result, t, t('systemManager.errors.actionFailed')),
t('common.error'),
);
return; return;
} }
const affectedContainer = (containers ?? []).find((container) => ( const affectedContainer = (containers ?? []).find((container) => (
@@ -339,7 +332,6 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
invalidateContainerInspectMatching, invalidateContainerInspectMatching,
refresh, refresh,
refreshContainerInspect, refreshContainerInspect,
runtime,
sessionId, sessionId,
t, t,
]); ]);
@@ -368,7 +360,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
backend, backend,
parentSession, parentSession,
`docker: ${container.name || id}`, `docker: ${container.name || id}`,
buildContainerExecShellCommand(runtime, id, parentSession.shellType), buildDockerExecShellCommand(id),
{ icon: await buildContainerPopupIcon(container.image) }, { icon: await buildContainerPopupIcon(container.image) },
); );
if (!result.success) { if (!result.success) {
@@ -380,7 +372,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
}); });
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error')); showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
} }
}, [backend, parentSession, runtime, sessionId, t]); }, [backend, parentSession, sessionId, t]);
const openLogs = useCallback(async (container: DockerContainerInfo) => { const openLogs = useCallback(async (container: DockerContainerInfo) => {
const id = container.id.slice(0, 12); const id = container.id.slice(0, 12);
@@ -395,7 +387,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
backend, backend,
parentSession, parentSession,
`logs: ${container.name || id}`, `logs: ${container.name || id}`,
buildContainerLogsCommand(runtime, id, parentSession.shellType), buildDockerLogsCommand(id),
{ icon: await buildContainerPopupIcon(container.image) }, { icon: await buildContainerPopupIcon(container.image) },
); );
if (!result.success) { if (!result.success) {
@@ -407,7 +399,7 @@ export const DockerContainersPanel = memo(function DockerContainersPanel({
}); });
showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error')); showSystemManagerError(result.error || t('systemManager.errors.actionFailed'), t('common.error'));
} }
}, [backend, parentSession, runtime, sessionId, t]); }, [backend, parentSession, sessionId, t]);
return ( return (
<div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers"> <div className="flex flex-col flex-1 min-h-0 overflow-hidden" data-section="docker-containers">
Loaded 100 of 386 files, more files were not shown because too many files have changed in this diff. Show more